diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 3bf2a12..0000000 --- a/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -dist -*/node_modules -Dockerfile* diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs deleted file mode 100644 index 2300230..0000000 --- a/.git-blame-ignore-revs +++ /dev/null @@ -1,11 +0,0 @@ -# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view - -# Run prettier (https://github.com/binwiederhier/ntfy/pull/746) -6f6a2d1f693070bf72e89d86748080e4825c9164 -c87549e71a10bc789eac8036078228f06e515a8e -ca5d736a7169eb6b4b0d849e061d5bf9565dcc53 -2e27f58963feb9e4d1c573d4745d07770777fa7d - -# Run eslint (https://github.com/binwiederhier/ntfy/pull/748) -f558b4dbe9bb5b9e0e87fada1215de2558353173 -8319f1cf26113167fb29fe12edaff5db74caf35f diff --git a/.github/ISSUE_TEMPLATE/1_bug_report.md b/.github/ISSUE_TEMPLATE/1_bug_report.md deleted file mode 100644 index 90ff2b2..0000000 --- a/.github/ISSUE_TEMPLATE/1_bug_report.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: 🐛 Bug Report -about: Report any errors and problems -title: '' -labels: '🪲 bug' -assignees: '' - ---- - -:lady_beetle: **Describe the bug** - - -:computer: **Components impacted** - - -:bulb: **Screenshots and/or logs** - - -:crystal_ball: **Additional context** - diff --git a/.github/ISSUE_TEMPLATE/2_enhancement_request.md b/.github/ISSUE_TEMPLATE/2_enhancement_request.md deleted file mode 100644 index 790ded1..0000000 --- a/.github/ISSUE_TEMPLATE/2_enhancement_request.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: 💡 Feature/Enhancement Request -about: Got a great idea? Let us know! -title: '' -labels: 'enhancement' -assignees: '' - ---- - - - -:bulb: **Idea** - - -:computer: **Target components** - - - diff --git a/.github/ISSUE_TEMPLATE/3_tech_support.md b/.github/ISSUE_TEMPLATE/3_tech_support.md deleted file mode 100644 index 82afe7a..0000000 --- a/.github/ISSUE_TEMPLATE/3_tech_support.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: 🆘 I need help with ... -about: Installing ntfy, configuring the app, etc. -title: '' -labels: 'tech-support' -assignees: '' - ---- - - - diff --git a/.github/ISSUE_TEMPLATE/4_question.md b/.github/ISSUE_TEMPLATE/4_question.md deleted file mode 100644 index 9d930ef..0000000 --- a/.github/ISSUE_TEMPLATE/4_question.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: ❓ Question -about: Ask a question about ntfy -title: '' -labels: 'question' -assignees: '' - ---- - - - -:question: **Question** - diff --git a/.github/images/logo.png b/.github/images/logo.png deleted file mode 100644 index 351db4d..0000000 Binary files a/.github/images/logo.png and /dev/null differ diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0076c0f..92816e6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,21 +4,30 @@ jobs: build: runs-on: ubuntu-latest steps: - - - name: Checkout code - uses: actions/checkout@v3 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v2 with: go-version: '1.19.x' - name: Install node - uses: actions/setup-node@v3 + uses: actions/setup-node@v2 with: - node-version: '18' - cache: 'npm' - cache-dependency-path: './web/package-lock.json' + node-version: '17' + - + name: Checkout code + uses: actions/checkout@v2 + - + name: Cache Go and npm modules + uses: actions/cache@v3 + with: + path: | + ~/go/pkg/mod + ~/go/bin + ~/.npm + web/node_modules + key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }} + restore-keys: ${{ runner.os }}-ntfy- - name: Install dependencies run: make build-deps-ubuntu diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 6991dea..2ba9b9c 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -30,7 +30,7 @@ jobs: run: | cd build/ntfy-docs.github.io git config user.name "GitHub Actions Bot" - git config user.email "" + git config user.email "<>" git add docs/ git commit -m "Updated docs" git push origin main diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f709332..be13b96 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,21 +7,30 @@ jobs: release: runs-on: ubuntu-latest steps: - - - name: Checkout code - uses: actions/checkout@v3 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v2 with: go-version: '1.19.x' - name: Install node - uses: actions/setup-node@v3 + uses: actions/setup-node@v2 with: - node-version: '18' - cache: 'npm' - cache-dependency-path: './web/package-lock.json' + node-version: '17' + - + name: Checkout code + uses: actions/checkout@v2 + - + name: Cache Go and npm modules + uses: actions/cache@v3 + with: + path: | + ~/go/pkg/mod + ~/go/bin + ~/.npm + web/node_modules + key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }} + restore-keys: ${{ runner.os }}-ntfy- - name: Docker login uses: docker/login-action@v2 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 7473567..544857c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,21 +4,30 @@ jobs: test: runs-on: ubuntu-latest steps: - - - name: Checkout code - uses: actions/checkout@v3 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v2 with: go-version: '1.19.x' - name: Install node - uses: actions/setup-node@v3 + uses: actions/setup-node@v2 with: - node-version: '18' - cache: 'npm' - cache-dependency-path: './web/package-lock.json' + node-version: '17' + - + name: Checkout code + uses: actions/checkout@v2 + - + name: Cache Go and npm modules + uses: actions/cache@v3 + with: + path: | + ~/go/pkg/mod + ~/go/bin + ~/.npm + web/node_modules + key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }} + restore-keys: ${{ runner.os }}-ntfy- - name: Install dependencies run: make build-deps-ubuntu diff --git a/.gitignore b/.gitignore index f695607..2b566b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ dist/ -dev-dist/ build/ .idea/ .vscode/ @@ -12,4 +11,3 @@ secrets/ *.iml node_modules/ .DS_Store -__pycache__ diff --git a/.goreleaser.yml b/.goreleaser.yml index 131a302..9ba8bb4 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -97,7 +97,7 @@ nfpms: - dst: /var/lib/ntfy type: dir - dst: /usr/share/ntfy/logo.png - src: web/public/static/images/ntfy.png + src: web/public/static/img/ntfy.png scripts: preinstall: "scripts/preinst.sh" postinstall: "scripts/postinst.sh" diff --git a/Dockerfile b/Dockerfile index feb813f..52ccff6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,9 @@ -FROM r.batts.cloud/debian:testing - -LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" -LABEL org.opencontainers.image.url="https://ntfy.sh/" -LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" -LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" -LABEL org.opencontainers.image.vendor="Philipp C. Heckel" -LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" -LABEL org.opencontainers.image.title="ntfy" -LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" +FROM alpine +MAINTAINER Philipp C. Heckel COPY ntfy /usr/bin +HEALTHCHECK --interval=60s --timeout=10s CMD wget -q --tries=1 http://localhost/v1/health -O - | grep -Eo '"healthy"\s*:\s*true' || exit 1 + EXPOSE 80/tcp ENTRYPOINT ["ntfy"] diff --git a/Dockerfile-build b/Dockerfile-build deleted file mode 100644 index 9c6d1bc..0000000 --- a/Dockerfile-build +++ /dev/null @@ -1,54 +0,0 @@ -FROM r.batts.cloud/golang:1.19 as builder - -ARG VERSION=dev -ARG COMMIT=unknown - -RUN apt-get update -RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -RUN apt-get install -y \ - build-essential \ - nodejs \ - python3-pip \ - python3-venv - -WORKDIR /app -ADD Makefile . - -# docs -ADD ./requirements.txt . -RUN make docs-deps -ADD ./mkdocs.yml . -ADD ./docs ./docs -RUN make docs-build - -# web -ADD ./web/package.json ./web/package-lock.json ./web/ -RUN make web-deps -ADD ./web ./web -RUN make web-build - -# cli & server -ADD go.mod go.sum main.go ./ -ADD ./client ./client -ADD ./cmd ./cmd -ADD ./log ./log -ADD ./server ./server -ADD ./user ./user -ADD ./util ./util -RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server - -FROM r.batts.cloud/debian:testing - -LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" -LABEL org.opencontainers.image.url="https://ntfy.sh/" -LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" -LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" -LABEL org.opencontainers.image.vendor="Philipp C. Heckel" -LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" -LABEL org.opencontainers.image.title="ntfy" -LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" - -COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy - -EXPOSE 80/tcp -ENTRYPOINT ["ntfy"] diff --git a/Makefile b/Makefile index 440bfa6..8ca86da 100644 --- a/Makefile +++ b/Makefile @@ -31,16 +31,10 @@ help: @echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)" @echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)" @echo - @echo "Build dev Docker:" - @echo " make docker-dev - Build client & server for current architecture using Docker only" - @echo @echo "Build web app:" @echo " make web - Build the web app" @echo " make web-deps - Install web app dependencies (npm install the universe)" @echo " make web-build - Actually build the web app" - @echo " make web-lint - Run eslint on the web app" - @echo " make web-format - Run prettier on the web app" - @echo " make web-format-check - Run prettier on the web app, but don't change anything" @echo @echo "Build documentation:" @echo " make docs - Build the documentation" @@ -86,33 +80,23 @@ build: web docs cli update: web-deps-update cli-deps-update docs-deps-update docker pull alpine -docker-dev: - docker build \ - --file ./Dockerfile-build \ - --tag binwiederhier/ntfy:$(VERSION) \ - --tag binwiederhier/ntfy:dev \ - --build-arg VERSION=$(VERSION) \ - --build-arg COMMIT=$(COMMIT) \ - ./ - # Ubuntu-specific build-deps-ubuntu: - sudo apt-get update - sudo apt-get install -y \ + sudo apt update + sudo apt install -y \ curl \ gcc-aarch64-linux-gnu \ gcc-arm-linux-gnueabi \ jq - which pip3 || sudo apt-get install -y python3-pip + which pip3 || sudo apt install -y python3-pip # Documentation docs: docs-deps docs-build -docs-build: venv .PHONY - @. venv/bin/activate && \ - if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \ +docs-build: .PHONY + @if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \ if which python3.8; then \ echo "python3.8 $(shell which mkdocs) build"; \ python3.8 $(shell which mkdocs) build; \ @@ -125,15 +109,10 @@ docs-build: venv .PHONY mkdocs build; \ fi -venv: - python3 -m venv ./venv - -docs-deps: venv .PHONY - . venv/bin/activate && \ +docs-deps: .PHONY pip3 install -r requirements.txt -docs-deps-update: venv .PHONY - . venv/bin/activate && \ +docs-deps-update: .PHONY pip3 install -r requirements.txt --upgrade @@ -148,7 +127,8 @@ web-build: && rm -rf ../server/site \ && mv build ../server/site \ && rm \ - ../server/site/config.js + ../server/site/config.js \ + ../server/site/asset-manifest.json web-deps: cd web && npm install @@ -157,37 +137,29 @@ web-deps: web-deps-update: cd web && npm update -web-format: - cd web && npm run format - -web-format-check: - cd web && npm run format:check - -web-lint: - cd web && npm run lint # Main server/client build cli: cli-deps - goreleaser build --snapshot --clean + goreleaser build --snapshot --rm-dist cli-linux-amd64: cli-deps-static-sites - goreleaser build --snapshot --clean --id ntfy_linux_amd64 + goreleaser build --snapshot --rm-dist --id ntfy_linux_amd64 cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7 - goreleaser build --snapshot --clean --id ntfy_linux_armv6 + goreleaser build --snapshot --rm-dist --id ntfy_linux_armv6 cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7 - goreleaser build --snapshot --clean --id ntfy_linux_armv7 + goreleaser build --snapshot --rm-dist --id ntfy_linux_armv7 cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64 - goreleaser build --snapshot --clean --id ntfy_linux_arm64 + goreleaser build --snapshot --rm-dist --id ntfy_linux_arm64 cli-windows-amd64: cli-deps-static-sites - goreleaser build --snapshot --clean --id ntfy_windows_amd64 + goreleaser build --snapshot --rm-dist --id ntfy_windows_amd64 cli-darwin-all: cli-deps-static-sites - goreleaser build --snapshot --clean --id ntfy_darwin_all + goreleaser build --snapshot --rm-dist --id ntfy_darwin_all cli-linux-server: cli-deps-static-sites # This is a target to build the CLI (including the server) manually. @@ -254,7 +226,7 @@ cli-build-results: # Test/check targets -check: test web-format-check fmt-check vet web-lint lint staticcheck +check: test fmt-check vet lint staticcheck test: .PHONY go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') @@ -305,11 +277,11 @@ staticcheck: .PHONY # Releasing targets -release: clean cli-deps release-checks docs web check - goreleaser release --clean +release: clean update cli-deps release-checks docs web check + goreleaser release --rm-dist -release-snapshot: clean cli-deps docs web check - goreleaser release --snapshot --skip-publish --clean +release-snapshot: clean update cli-deps docs web check + goreleaser release --snapshot --skip-publish --rm-dist release-checks: $(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) diff --git a/README.md b/README.md index cebf55b..ca977ec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![ntfy](web/public/static/images/ntfy.png) +![ntfy](web/public/static/img/ntfy.png) # ntfy.sh | Send push notifications to your phone or desktop via PUT/POST [![Release](https://img.shields.io/github/release/binwiederhier/ntfy.svg?color=success&style=flat-square)](https://github.com/binwiederhier/ntfy/releases/latest) @@ -13,26 +13,20 @@ [![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/) [![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy) -**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) -notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer, -**without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do -so since ntfy is open source. +**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service. +It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**. +It's also open source (as you can plainly see) if you want to run your own. -You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android) -available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), -as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347). +I run a free version of it at **[ntfy.sh](https://ntfy.sh)**. There's also an [open source Android app](https://github.com/binwiederhier/ntfy-android) (see [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/)), and an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) (see [App Store](https://apps.apple.com/us/app/ntfy/id1625396347)).

- - - - - + + + + +

-## [ntfy Pro](https://ntfy.sh/app) 💸 🎉 -I now offer paid plans for [ntfy.sh](https://ntfy.sh/) if you don't want to self-host, or you want to support the development of ntfy (→ [Purchase via web app](https://ntfy.sh/app)). You can **buy a plan for as low as $3.33/month** (if you use promo code `MYTOPIC`, limited time only). You can also donate via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), and [Liberapay](https://liberapay.com/ntfy). I would be very humbled by your sponsorship. ❤️ - ## **[Documentation](https://ntfy.sh/docs/)** [Getting started](https://ntfy.sh/docs/) | @@ -123,24 +117,6 @@ account costs. Even small donations are very much appreciated. A big fat **Thank - - - - - - - - - - - - - - - - - - I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free, and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project: diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 4557375..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,10 +0,0 @@ -# Security Policy - -## Supported Versions - -As of today, I only support the latest version of ntfy. Please make sure you stay up-to-date. - -## Reporting a Vulnerability - -Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w), -or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`). diff --git a/client/client.go b/client/client.go index 93cf7da..b744fa1 100644 --- a/client/client.go +++ b/client/client.go @@ -11,25 +11,23 @@ import ( "heckel.io/ntfy/util" "io" "net/http" - "regexp" "strings" "sync" "time" ) +// Event type constants const ( - // MessageEvent identifies a message event - MessageEvent = "message" + MessageEvent = "message" + KeepaliveEvent = "keepalive" + OpenEvent = "open" + PollRequestEvent = "poll_request" ) const ( maxResponseBytes = 4096 ) -var ( - topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go -) - // Client is the ntfy client that can be used to publish and subscribe to ntfy topics type Client struct { Messages chan *Message @@ -98,14 +96,8 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess // To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache, // WithNoFirebase, and the generic WithHeader. func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) { - topicURL, err := c.expandTopicURL(topic) - if err != nil { - return nil, err - } - req, err := http.NewRequest("POST", topicURL, body) - if err != nil { - return nil, err - } + topicURL := c.expandTopicURL(topic) + req, _ := http.NewRequest("POST", topicURL, body) for _, option := range options { if err := option(req); err != nil { return nil, err @@ -141,14 +133,11 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO // By default, all messages will be returned, but you can change this behavior using a SubscribeOption. // See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) { - topicURL, err := c.expandTopicURL(topic) - if err != nil { - return nil, err - } ctx := context.Background() messages := make([]*Message, 0) msgChan := make(chan *Message) errChan := make(chan error) + topicURL := c.expandTopicURL(topic) log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL)) options = append(options, WithPoll()) go func() { @@ -177,18 +166,15 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err // Example: // // c := client.New(client.NewConfig()) -// subscriptionID, _ := c.Subscribe("mytopic") +// subscriptionID := c.Subscribe("mytopic") // for m := range c.Messages { // fmt.Printf("New message: %s", m.Message) // } -func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) { - topicURL, err := c.expandTopicURL(topic) - if err != nil { - return "", err - } +func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { c.mu.Lock() defer c.mu.Unlock() subscriptionID := util.RandomString(10) + topicURL := c.expandTopicURL(topic) log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL)) ctx, cancel := context.WithCancel(context.Background()) c.subscriptions[subscriptionID] = &subscription{ @@ -197,7 +183,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, er cancel: cancel, } go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...) - return subscriptionID, nil + return subscriptionID } // Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique @@ -213,16 +199,31 @@ func (c *Client) Unsubscribe(subscriptionID string) { sub.cancel() } -func (c *Client) expandTopicURL(topic string) (string, error) { +// UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe. +// If there are multiple subscriptions matching the topic, all of them are unsubscribed from. +// +// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// +// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the +// config (e.g. mytopic -> https://ntfy.sh/mytopic). +func (c *Client) UnsubscribeAll(topic string) { + c.mu.Lock() + defer c.mu.Unlock() + topicURL := c.expandTopicURL(topic) + for _, sub := range c.subscriptions { + if sub.topicURL == topicURL { + delete(c.subscriptions, sub.ID) + sub.cancel() + } + } +} + +func (c *Client) expandTopicURL(topic string) string { if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") { - return topic, nil + return topic } else if strings.Contains(topic, "/") { - return fmt.Sprintf("https://%s", topic), nil + return fmt.Sprintf("https://%s", topic) } - if !topicRegex.MatchString(topic) { - return "", fmt.Errorf("invalid topic name: %s", topic) - } - return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil + return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic) } func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) { diff --git a/client/client.yml b/client/client.yml index 1b81b80..d3ba272 100644 --- a/client/client.yml +++ b/client/client.yml @@ -5,12 +5,10 @@ # # default-host: https://ntfy.sh -# Default credentials will be used with "ntfy publish" and "ntfy subscribe" if no other credentials are provided. -# You can set a default token to use or a default user:password combination, but not both. For an empty password, -# use empty double-quotes ("") - -# default-token: - +# Default username and password will be used with "ntfy publish" if no credentials are provided on command line +# Default username and password will be used with "ntfy subscribe" if no credentials are provided in subscription below +# For an empty password, use empty double-quotes ("") +# # default-user: # default-password: @@ -32,8 +30,6 @@ # command: 'notify-send "$m"' # user: phill # password: mypass -# - topic: token_topic -# token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 # # Variables: # Variable Aliases Description diff --git a/client/client_test.go b/client/client_test.go index f0b15a3..a71ea5c 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -21,7 +21,7 @@ func TestClient_Publish_Subscribe(t *testing.T) { defer test.StopServer(t, s, port) c := client.New(newTestConfig(port)) - subscriptionID, _ := c.Subscribe("mytopic") + subscriptionID := c.Subscribe("mytopic") time.Sleep(time.Second) msg, err := c.Publish("mytopic", "some message") diff --git a/client/config.go b/client/config.go index d4337d4..b2efc1d 100644 --- a/client/config.go +++ b/client/config.go @@ -12,22 +12,17 @@ const ( // Config is the config struct for a Client type Config struct { - DefaultHost string `yaml:"default-host"` - DefaultUser string `yaml:"default-user"` - DefaultPassword *string `yaml:"default-password"` - DefaultToken string `yaml:"default-token"` - DefaultCommand string `yaml:"default-command"` - Subscribe []Subscribe `yaml:"subscribe"` -} - -// Subscribe is the struct for a Subscription within Config -type Subscribe struct { - Topic string `yaml:"topic"` - User string `yaml:"user"` - Password *string `yaml:"password"` - Token string `yaml:"token"` - Command string `yaml:"command"` - If map[string]string `yaml:"if"` + DefaultHost string `yaml:"default-host"` + DefaultUser string `yaml:"default-user"` + DefaultPassword *string `yaml:"default-password"` + DefaultCommand string `yaml:"default-command"` + Subscribe []struct { + Topic string `yaml:"topic"` + User string `yaml:"user"` + Password *string `yaml:"password"` + Command string `yaml:"command"` + If map[string]string `yaml:"if"` + } `yaml:"subscribe"` } // NewConfig creates a new Config struct for a Client @@ -36,7 +31,6 @@ func NewConfig() *Config { DefaultHost: DefaultBaseURL, DefaultUser: "", DefaultPassword: nil, - DefaultToken: "", DefaultCommand: "", Subscribe: nil, } diff --git a/client/config_test.go b/client/config_test.go index f22e6b2..0a71c3b 100644 --- a/client/config_test.go +++ b/client/config_test.go @@ -116,25 +116,3 @@ subscribe: require.Equal(t, "phil", conf.Subscribe[0].User) require.Nil(t, conf.Subscribe[0].Password) } - -func TestConfig_DefaultToken(t *testing.T) { - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(` -default-host: http://localhost -default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -subscribe: - - topic: mytopic -`), 0600)) - - conf, err := client.LoadConfig(filename) - require.Nil(t, err) - require.Equal(t, "http://localhost", conf.DefaultHost) - require.Equal(t, "", conf.DefaultUser) - require.Nil(t, conf.DefaultPassword) - require.Equal(t, "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", conf.DefaultToken) - require.Equal(t, 1, len(conf.Subscribe)) - require.Equal(t, "mytopic", conf.Subscribe[0].Topic) - require.Equal(t, "", conf.Subscribe[0].User) - require.Nil(t, conf.Subscribe[0].Password) - require.Equal(t, "", conf.Subscribe[0].Token) -} diff --git a/cmd/publish.go b/cmd/publish.go index 0179f9f..be00dfd 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -40,6 +40,7 @@ var flagsPublish = append( &cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"}, &cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"}, &cli.BoolFlag{Name: "no-firebase", Aliases: []string{"no_firebase", "F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"}, + &cli.BoolFlag{Name: "env-topic", Aliases: []string{"env_topic", "P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"}, &cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"}, ) @@ -154,7 +155,8 @@ func execPublish(c *cli.Context) error { } if token != "" { options = append(options, client.WithBearerAuth(token)) - } else if user != "" { + } + if user != "" { var pass string parts := strings.SplitN(user, ":", 2) if len(parts) == 2 { @@ -170,8 +172,6 @@ func execPublish(c *cli.Context) error { fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) } options = append(options, client.WithBasicAuth(user, pass)) - } else if conf.DefaultToken != "" { - options = append(options, client.WithBearerAuth(conf.DefaultToken)) } else if conf.DefaultUser != "" && conf.DefaultPassword != nil { options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) } diff --git a/cmd/publish_test.go b/cmd/publish_test.go index a254f47..1c6a14a 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -5,11 +5,8 @@ import ( "github.com/stretchr/testify/require" "heckel.io/ntfy/test" "heckel.io/ntfy/util" - "net/http" - "net/http/httptest" "os" "os/exec" - "path/filepath" "strconv" "strings" "testing" @@ -133,11 +130,11 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) { require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error()) // Tests with NTFY_TOPIC set //// - t.Setenv("NTFY_TOPIC", topic) + require.Nil(t, os.Setenv("NTFY_TOPIC", topic)) // Test: Successful command with NTFY_TOPIC app, _, stdout, _ = newTestApp() - require.Nil(t, app.Run([]string{"ntfy", "publish", "--cmd", "echo", "hi there"})) + require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"})) m = toMessage(t, stdout.String()) require.Equal(t, "mytopic", m.Topic) @@ -146,155 +143,7 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) { require.Nil(t, sleep.Start()) go sleep.Wait() // Must be called to release resources app, _, stdout, _ = newTestApp() - require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid)})) + require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--wait-pid", strconv.Itoa(sleep.Process.Pid)})) m = toMessage(t, stdout.String()) require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message) } - -func TestCLI_Publish_Default_UserPass(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-user: philipp -default-password: mypass -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"})) - m := toMessage(t, stdout.String()) - require.Equal(t, "triggered", m.Message) -} - -func TestCLI_Publish_Default_Token(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"})) - m := toMessage(t, stdout.String()) - require.Equal(t, "triggered", m.Message) -} - -func TestCLI_Publish_Default_UserPass_CLI_Token(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-user: philipp -default-password: mypass -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"})) - m := toMessage(t, stdout.String()) - require.Equal(t, "triggered", m.Message) -} - -func TestCLI_Publish_Default_Token_CLI_UserPass(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"})) - m := toMessage(t, stdout.String()) - require.Equal(t, "triggered", m.Message) -} - -func TestCLI_Publish_Default_Token_CLI_Token(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-token: tk_FAKETOKEN01234567890FAKETOKEN -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"})) - m := toMessage(t, stdout.String()) - require.Equal(t, "triggered", m.Message) -} - -func TestCLI_Publish_Default_UserPass_CLI_UserPass(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-user: philipp -default-password: fakepass -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"})) - m := toMessage(t, stdout.String()) - require.Equal(t, "triggered", m.Message) -} - -func TestCLI_Publish_Token_And_UserPass(t *testing.T) { - app, _, _, _ := newTestApp() - err := app.Run([]string{"ntfy", "publish", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) - require.Error(t, err) - require.Equal(t, "cannot set both --user and --token", err.Error()) -} diff --git a/cmd/serve.go b/cmd/serve.go index 5d5381b..3b98fc2 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -37,8 +37,8 @@ var flagsServe = append( append([]cli.Flag{}, flagsDefault...), &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}), @@ -59,12 +59,11 @@ var flagsServe = append( altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), @@ -72,10 +71,6 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), @@ -86,14 +81,9 @@ var flagsServe = append( altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), - altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}), - altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}), ) var cmdServe = &cli.Command{ @@ -149,7 +139,6 @@ func execServe(c *cli.Context) error { enableLogin := c.Bool("enable-login") enableReservations := c.Bool("enable-reservations") upstreamBaseURL := c.String("upstream-base-url") - upstreamAccessToken := c.String("upstream-access-token") smtpSenderAddr := c.String("smtp-sender-addr") smtpSenderUser := c.String("smtp-sender-user") smtpSenderPass := c.String("smtp-sender-pass") @@ -157,13 +146,8 @@ func execServe(c *cli.Context) error { smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") - twilioAccount := c.String("twilio-account") - twilioAuthToken := c.String("twilio-auth-token") - twilioPhoneNumber := c.String("twilio-phone-number") - twilioVerifyService := c.String("twilio-verify-service") totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") - visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit") visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") @@ -175,10 +159,6 @@ func execServe(c *cli.Context) error { behindProxy := c.Bool("behind-proxy") stripeSecretKey := c.String("stripe-secret-key") stripeWebhookKey := c.String("stripe-webhook-key") - billingContact := c.String("billing-contact") - metricsListenHTTP := c.String("metrics-listen-http") - enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != "" - profileListenHTTP := c.String("profile-listen-http") // Check values if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { @@ -195,8 +175,8 @@ func execServe(c *cli.Context) error { return errors.New("if set, certificate file must exist") } else if listenHTTPS != "" && (keyFile == "" || certFile == "") { return errors.New("if listen-https is set, both key-file and cert-file must be set") - } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") { - return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set") + } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") { + return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set") } else if smtpServerListen != "" && smtpServerDomain == "" { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { @@ -205,6 +185,8 @@ func execServe(c *cli.Context) error { return errors.New("if set, base-url must start with http:// or https://") } else if baseURL != "" && strings.HasSuffix(baseURL, "/") { return errors.New("if set, base-url must not end with a slash (/)") + } else if !util.Contains([]string{"app", "home", "disable"}, webRoot) { + return errors.New("if set, web-root must be 'home' or 'app'") } else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") { return errors.New("if set, upstream-base-url must start with http:// or https://") } else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") { @@ -219,20 +201,10 @@ func execServe(c *cli.Context) error { return errors.New("cannot set enable-signup without also setting enable-login") } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") - } else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { - return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set") } - // Backwards compatibility - if webRoot == "app" { - webRoot = "/" - } else if webRoot == "home" { - webRoot = "/app" - } else if webRoot == "disable" { - webRoot = "" - } else if !strings.HasPrefix(webRoot, "/") { - webRoot = "/" + webRoot - } + webRootIsApp := webRoot == "app" + enableWeb := webRoot != "disable" // Default auth permissions authDefault, err := user.ParsePermission(authDefaultAccess) @@ -278,7 +250,6 @@ func execServe(c *cli.Context) error { // Stripe things if stripeSecretKey != "" { - stripe.EnableTelemetry = false // Whoa! stripe.Key = stripeSecretKey } @@ -311,9 +282,8 @@ func execServe(c *cli.Context) error { conf.KeepaliveInterval = keepaliveInterval conf.ManagerInterval = managerInterval conf.DisallowedTopics = disallowedTopics - conf.WebRoot = webRoot + conf.WebRootIsApp = webRootIsApp conf.UpstreamBaseURL = upstreamBaseURL - conf.UpstreamAccessToken = upstreamAccessToken conf.SMTPSenderAddr = smtpSenderAddr conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderPass = smtpSenderPass @@ -321,10 +291,6 @@ func execServe(c *cli.Context) error { conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix - conf.TwilioAccount = twilioAccount - conf.TwilioAuthToken = twilioAuthToken - conf.TwilioPhoneNumber = twilioPhoneNumber - conf.TwilioVerifyService = twilioVerifyService conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit @@ -335,17 +301,13 @@ func execServe(c *cli.Context) error { conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish - conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting conf.BehindProxy = behindProxy conf.StripeSecretKey = stripeSecretKey conf.StripeWebhookKey = stripeWebhookKey - conf.BillingContact = billingContact + conf.EnableWeb = enableWeb conf.EnableSignup = enableSignup conf.EnableLogin = enableLogin conf.EnableReservations = enableReservations - conf.EnableMetrics = enableMetrics - conf.MetricsListenHTTP = metricsListenHTTP - conf.ProfileListenHTTP = profileListenHTTP conf.Version = c.App.Version // Set up hot-reloading of config diff --git a/cmd/subscribe.go b/cmd/subscribe.go index c85c468..bbc6fb3 100644 --- a/cmd/subscribe.go +++ b/cmd/subscribe.go @@ -30,7 +30,6 @@ var flagsSubscribe = append( &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"}, &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, - &cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"}, &cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"}, &cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"}, &cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"}, @@ -72,7 +71,7 @@ ntfy subscribe TOPIC COMMAND $NTFY_TITLE $title, $t Message title $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max) $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list) - $NTFY_RAW $raw Raw JSON message + $NTFY_RAW $raw Raw JSON message Examples: ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages @@ -98,18 +97,11 @@ func execSubscribe(c *cli.Context) error { cl := client.New(conf) since := c.String("since") user := c.String("user") - token := c.String("token") poll := c.Bool("poll") scheduled := c.Bool("scheduled") fromConfig := c.Bool("from-config") topic := c.Args().Get(0) command := c.Args().Get(1) - - // Checks - if user != "" && token != "" { - return errors.New("cannot set both --user and --token") - } - if !fromConfig { conf.Subscribe = nil // wipe if --from-config not passed } @@ -117,9 +109,7 @@ func execSubscribe(c *cli.Context) error { if since != "" { options = append(options, client.WithSince(since)) } - if token != "" { - options = append(options, client.WithBearerAuth(token)) - } else if user != "" { + if user != "" { var pass string parts := strings.SplitN(user, ":", 2) if len(parts) == 2 { @@ -135,10 +125,9 @@ func execSubscribe(c *cli.Context) error { fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) } options = append(options, client.WithBasicAuth(user, pass)) - } else if conf.DefaultToken != "" { - options = append(options, client.WithBearerAuth(conf.DefaultToken)) - } else if conf.DefaultUser != "" && conf.DefaultPassword != nil { - options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) + } + if poll { + options = append(options, client.WithPoll()) } if scheduled { options = append(options, client.WithScheduled()) @@ -156,9 +145,6 @@ func execSubscribe(c *cli.Context) error { func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error { for _, s := range conf.Subscribe { // may be nil - if auth := maybeAddAuthHeader(s, conf); auth != nil { - options = append(options, auth) - } if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil { return err } @@ -189,15 +175,22 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, for filter, value := range s.If { topicOptions = append(topicOptions, client.WithFilter(filter, value)) } - - if auth := maybeAddAuthHeader(s, conf); auth != nil { - topicOptions = append(topicOptions, auth) + var user string + var password *string + if s.User != "" { + user = s.User + } else if conf.DefaultUser != "" { + user = conf.DefaultUser } - - subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...) - if err != nil { - return err + if s.Password != nil { + password = s.Password + } else if conf.DefaultPassword != nil { + password = conf.DefaultPassword } + if user != "" && password != nil { + topicOptions = append(topicOptions, client.WithBasicAuth(user, *password)) + } + subscriptionID := cl.Subscribe(s.Topic, topicOptions...) if s.Command != "" { cmds[subscriptionID] = s.Command } else if conf.DefaultCommand != "" { @@ -207,10 +200,7 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, } } if topic != "" { - subscriptionID, err := cl.Subscribe(topic, options...) - if err != nil { - return err - } + subscriptionID := cl.Subscribe(topic, options...) cmds[subscriptionID] = command } for m := range cl.Messages { @@ -224,25 +214,6 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, return nil } -func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption { - // check for subscription token then subscription user:pass - if s.Token != "" { - return client.WithBearerAuth(s.Token) - } - if s.User != "" && s.Password != nil { - return client.WithBasicAuth(s.User, *s.Password) - } - - // if no subscription token nor subscription user:pass, check for default token then default user:pass - if conf.DefaultToken != "" { - return client.WithBearerAuth(conf.DefaultToken) - } - if conf.DefaultUser != "" && conf.DefaultPassword != nil { - return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword) - } - return nil -} - func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) { if command != "" { runCommand(c, command, m) diff --git a/cmd/subscribe_test.go b/cmd/subscribe_test.go deleted file mode 100644 index 0b3a0a4..0000000 --- a/cmd/subscribe_test.go +++ /dev/null @@ -1,361 +0,0 @@ -package cmd - -import ( - "fmt" - "github.com/stretchr/testify/require" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-user: philipp -default-password: mypass -subscribe: - - topic: mytopic - token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_Token_Subscription_UserPass(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -subscribe: - - topic: mytopic - user: philipp - password: mypass -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_Token_Subscription_Token(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-token: tk_FAKETOKEN01234567890FAKETOKEN -subscribe: - - topic: mytopic - token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_UserPass_Subscription_UserPass(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-user: fake -default-password: password -subscribe: - - topic: mytopic - user: philipp - password: mypass -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_Token_Subscription_Empty(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -subscribe: - - topic: mytopic -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_UserPass_Subscription_Empty(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-user: philipp -default-password: mypass -subscribe: - - topic: mytopic -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_Empty_Subscription_Token(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -subscribe: - - topic: mytopic - token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_Empty_Subscription_UserPass(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -subscribe: - - topic: mytopic - user: philipp - password: mypass -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_Token_CLI_Token(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-token: tk_FAKETOKEN0123456789FAKETOKEN -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_Token_CLI_UserPass(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_Token_Subscription_Token_CLI_UserPass(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-token: tk_FAKETOKEN01234567890FAKETOKEN -subscribe: - - topic: mytopic - token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass"})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Token_And_UserPass(t *testing.T) { - app, _, _, _ := newTestApp() - err := app.Run([]string{"ntfy", "subscribe", "--poll", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"}) - require.Error(t, err) - require.Equal(t, "cannot set both --user and --token", err.Error()) -} - -func TestCLI_Subscribe_Default_Token(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--config=" + filename, "mytopic"})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} - -func TestCLI_Subscribe_Default_UserPass(t *testing.T) { - message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic/json", r.URL.Path) - require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization")) - - w.WriteHeader(http.StatusOK) - w.Write([]byte(message)) - })) - defer server.Close() - - filename := filepath.Join(t.TempDir(), "client.yml") - require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(` -default-host: %s -default-user: philipp -default-password: mypass -`, server.URL)), 0600)) - - app, _, stdout, _ := newTestApp() - - require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--config=" + filename, "mytopic"})) - - require.Equal(t, message, strings.TrimSpace(stdout.String())) -} diff --git a/cmd/tier.go b/cmd/tier.go index f1c8ddc..1c3ede9 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -8,6 +8,7 @@ import ( "github.com/urfave/cli/v2" "heckel.io/ntfy/user" "heckel.io/ntfy/util" + "time" ) func init() { @@ -16,13 +17,12 @@ func init() { const ( defaultMessageLimit = 5000 - defaultMessageExpiryDuration = "12h" + defaultMessageExpiryDuration = 12 * time.Hour defaultEmailLimit = 20 - defaultCallLimit = 0 defaultReservationLimit = 3 defaultAttachmentFileSizeLimit = "15M" defaultAttachmentTotalSizeLimit = "100M" - defaultAttachmentExpiryDuration = "6h" + defaultAttachmentExpiryDuration = 6 * time.Hour defaultAttachmentBandwidthLimit = "1G" ) @@ -47,16 +47,14 @@ var cmdTier = &cli.Command{ Flags: []cli.Flag{ &cli.StringFlag{Name: "name", Usage: "tier name"}, &cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"}, - &cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"}, + &cli.DurationFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"}, &cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"}, - &cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"}, &cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"}, &cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"}, &cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"}, - &cli.StringFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"}, + &cli.DurationFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"}, &cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"}, - &cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"}, - &cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"}, + &cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"}, &cli.BoolFlag{Name: "ignore-exists", Usage: "if the tier already exists, perform no action and exit"}, }, Description: `Add a new tier to the ntfy user database. @@ -91,16 +89,14 @@ Examples: Flags: []cli.Flag{ &cli.StringFlag{Name: "name", Usage: "tier name"}, &cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"}, - &cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"}, + &cli.DurationFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"}, &cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"}, - &cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"}, &cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"}, &cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"}, &cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"}, - &cli.StringFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"}, + &cli.DurationFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"}, &cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"}, - &cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"}, - &cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"}, + &cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"}, }, Description: `Updates a tier to change the limits. @@ -114,8 +110,7 @@ Examples: ntfy tier change --name="Pro" pro # Update the name of an existing tier ntfy tier change \ # Update multiple limits and fields --message-expiry-duration=24h \ - --stripe-monthly-price-id=price_1234 \ - --stripe-monthly-price-id=price_5678 \ + --stripe-price-id=price_1234 \ pro `, }, @@ -171,10 +166,6 @@ func execTierAdd(c *cli.Context) error { return errors.New("tier code expected, type 'ntfy tier add --help' for help") } else if !user.AllowedTier(code) { return errors.New("tier code must consist only of numbers and letters") - } else if c.String("stripe-monthly-price-id") != "" && c.String("stripe-yearly-price-id") == "" { - return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set") - } else if c.String("stripe-monthly-price-id") == "" && c.String("stripe-yearly-price-id") != "" { - return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set") } manager, err := createUserManager(c) if err != nil { @@ -191,10 +182,6 @@ func execTierAdd(c *cli.Context) error { if name == "" { name = code } - messageExpiryDuration, err := util.ParseDuration(c.String("message-expiry-duration")) - if err != nil { - return err - } attachmentFileSizeLimit, err := util.ParseSize(c.String("attachment-file-size-limit")) if err != nil { return err @@ -207,25 +194,19 @@ func execTierAdd(c *cli.Context) error { if err != nil { return err } - attachmentExpiryDuration, err := util.ParseDuration(c.String("attachment-expiry-duration")) - if err != nil { - return err - } tier := &user.Tier{ ID: "", // Generated Code: code, Name: name, MessageLimit: c.Int64("message-limit"), - MessageExpiryDuration: messageExpiryDuration, + MessageExpiryDuration: c.Duration("message-expiry-duration"), EmailLimit: c.Int64("email-limit"), - CallLimit: c.Int64("call-limit"), ReservationLimit: c.Int64("reservation-limit"), AttachmentFileSizeLimit: attachmentFileSizeLimit, AttachmentTotalSizeLimit: attachmentTotalSizeLimit, - AttachmentExpiryDuration: attachmentExpiryDuration, + AttachmentExpiryDuration: c.Duration("attachment-expiry-duration"), AttachmentBandwidthLimit: attachmentBandwidthLimit, - StripeMonthlyPriceID: c.String("stripe-monthly-price-id"), - StripeYearlyPriceID: c.String("stripe-yearly-price-id"), + StripePriceID: c.String("stripe-price-id"), } if err := manager.AddTier(tier); err != nil { return err @@ -263,17 +244,11 @@ func execTierChange(c *cli.Context) error { tier.MessageLimit = c.Int64("message-limit") } if c.IsSet("message-expiry-duration") { - tier.MessageExpiryDuration, err = util.ParseDuration(c.String("message-expiry-duration")) - if err != nil { - return err - } + tier.MessageExpiryDuration = c.Duration("message-expiry-duration") } if c.IsSet("email-limit") { tier.EmailLimit = c.Int64("email-limit") } - if c.IsSet("call-limit") { - tier.CallLimit = c.Int64("call-limit") - } if c.IsSet("reservation-limit") { tier.ReservationLimit = c.Int64("reservation-limit") } @@ -290,10 +265,7 @@ func execTierChange(c *cli.Context) error { } } if c.IsSet("attachment-expiry-duration") { - tier.AttachmentExpiryDuration, err = util.ParseDuration(c.String("attachment-expiry-duration")) - if err != nil { - return err - } + tier.AttachmentExpiryDuration = c.Duration("attachment-expiry-duration") } if c.IsSet("attachment-bandwidth-limit") { tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit")) @@ -301,16 +273,8 @@ func execTierChange(c *cli.Context) error { return err } } - if c.IsSet("stripe-monthly-price-id") { - tier.StripeMonthlyPriceID = c.String("stripe-monthly-price-id") - } - if c.IsSet("stripe-yearly-price-id") { - tier.StripeYearlyPriceID = c.String("stripe-yearly-price-id") - } - if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID == "" { - return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set") - } else if tier.StripeMonthlyPriceID == "" && tier.StripeYearlyPriceID != "" { - return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set") + if c.IsSet("stripe-price-id") { + tier.StripePriceID = c.String("stripe-price-id") } if err := manager.UpdateTier(tier); err != nil { return err @@ -355,20 +319,19 @@ func execTierList(c *cli.Context) error { } func printTier(c *cli.Context, tier *user.Tier) { - prices := "(none)" - if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" { - prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID) + stripePriceID := tier.StripePriceID + if stripePriceID == "" { + stripePriceID = "(none)" } fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID) fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name) fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit) fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit) - fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit) fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit) fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit)) fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit)) fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds())) fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit)) - fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices) + fmt.Fprintf(c.App.ErrWriter, "- Stripe price: %s\n", stripePriceID) } diff --git a/cmd/tier_test.go b/cmd/tier_test.go index 1774aa2..12343e4 100644 --- a/cmd/tier_test.go +++ b/cmd/tier_test.go @@ -29,25 +29,24 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) { app, _, _, stderr = newTestApp() require.Nil(t, runTierCommand(app, conf, "change", "--message-limit=999", - "--message-expiry-duration=2d", + "--message-expiry-duration=99h", "--email-limit=91", "--reservation-limit=98", "--attachment-file-size-limit=100m", - "--attachment-expiry-duration=1d", + "--attachment-expiry-duration=7h", "--attachment-total-size-limit=10G", "--attachment-bandwidth-limit=100G", - "--stripe-monthly-price-id=price_991", - "--stripe-yearly-price-id=price_992", + "--stripe-price-id=price_991", "pro", )) require.Contains(t, stderr.String(), "- Message limit: 999") - require.Contains(t, stderr.String(), "- Message expiry duration: 48h") + require.Contains(t, stderr.String(), "- Message expiry duration: 99h") require.Contains(t, stderr.String(), "- Email limit: 91") require.Contains(t, stderr.String(), "- Reservation limit: 98") require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB") - require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h") + require.Contains(t, stderr.String(), "- Attachment expiry duration: 7h") require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB") - require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992") + require.Contains(t, stderr.String(), "- Stripe price: price_991") app, _, _, stderr = newTestApp() require.Nil(t, runTierCommand(app, conf, "remove", "pro")) diff --git a/docs/_overrides/main.html b/docs/_overrides/main.html deleted file mode 100644 index 52483eb..0000000 --- a/docs/_overrides/main.html +++ /dev/null @@ -1,50 +0,0 @@ -{% extends "base.html" %} - -{% block announce %} - - -If you like ntfy, please consider sponsoring me via GitHub Sponsors -or Liberapay - - -, or subscribing to ntfy Pro. - -{% endblock %} diff --git a/docs/config.md b/docs/config.md index df1f2cd..5fca3eb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -759,7 +759,6 @@ To configure it, simply set `upstream-base-url` like so: ``` yaml upstream-base-url: "https://ntfy.sh" -upstream-access-token: "..." # optional, only if rate limits exceeded, or upstream server protected ``` If set, all incoming messages will publish a poll request to the configured upstream server, containing @@ -815,7 +814,6 @@ ntfy tier add \ --message-limit=10000 \ --message-expiry-duration=24h \ --email-limit=50 \ - --call-limit=10 \ --reservation-limit=10 \ --attachment-file-size-limit=100M \ --attachment-total-size-limit=1G \ @@ -841,8 +839,6 @@ config options: enables payments in the ntfy web app (e.g. Upgrade dialog). See [API keys](https://dashboard.stripe.com/apikeys). * `stripe-webhook-key` is the key required to validate the authenticity of incoming webhooks from Stripe. Webhooks are essential to keep the local database in sync with the payment provider. See [Webhooks](https://dashboard.stripe.com/webhooks). -* `billing-contact` is an email address or website displayed in the "Upgrade tier" dialog to let people reach - out with billing questions. If unset, nothing will be displayed. In addition to setting these two options, you also need to define a [Stripe webhook](https://dashboard.stripe.com/webhooks) for the `customer.subscription.updated` and `customer.subscription.deleted` event, which points @@ -853,25 +849,8 @@ Here's an example: ``` yaml stripe-secret-key: "sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG" stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR" -billing-contact: "phil@example.com" ``` -## Phone calls -ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a call provider. If phone calls are enabled, -users can verify and add a phone number, and then receive phone calls when publishing a message using the `X-Call` header. -See [publishing page](publish.md#phone-calls) for more details. - -To enable Twilio integration, sign up with [Twilio](https://www.twilio.com/), purchase a phone number (Toll free numbers -are the easiest), and then configure the following options: - -* `twilio-account` is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 -* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586 -* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586 -* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 - -After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), -and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. - ## Rate limiting !!! info Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. @@ -950,25 +929,6 @@ If this ever happens, there will be a log message that looks something like this WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor ``` -### Subscriber-based rate limiting -By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment -size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits -of a topic's subscriber, instead of the limits of the publisher.** - -If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed -to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume -publishers (e.g. Matrix/Mastodon servers) are allowed to send. - -Once enabled, a client may send a `Rate-Topics: ,,...` header when subscribing to topics via -HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits -to use when publishing on this topic. Note that setting the rate visitor requires **read-write permission** on the topic. - -UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage` -response if no "rate visitor" has been previously registered. This is to avoid burning the publisher's -`visitor-message-daily-limit`. - -To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`. - ## Tuning for scale If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. @@ -1107,60 +1067,6 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a maxretry = 10 ``` -## Health checks -A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below. -If a non-200 HTTP status code is returned or if the returned `health` field is `false` the ntfy service should be considered as unhealthy. - -```json -{"health":true} -``` - -See [Installation for Docker](install.md#docker) for an example of how this could be used in a `docker-compose` environment. - -## Monitoring -If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to -create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)). - -To configure the metrics endpoint, either set `enable-metrics` and/or set the `listen-metrics-http` option to a dedicated -listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are -doing, and/or secure access to the endpoint in your reverse proxy. - -- `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket) -- `metrics-listen-http` exposes the metrics endpoint via a dedicated `[IP]:port`. If set, this option implicitly - enables metrics as well, e.g. "10.0.1.1:9090" or ":9090" - -=== "server.yml (Using default port)" - ```yaml - enable-metrics: true - ``` - -=== "server.yml (Using dedicated IP/port)" - ```yaml - metrics-listen-http: "10.0.1.1:9090" - ``` - -In Prometheus, an example scrape config would look like this: - -=== "prometheus.yml" - ```yaml - scrape_configs: - - job_name: "ntfy" - static_configs: - - targets: ["10.0.1.1:9090"] - ``` - -Here's an example Grafana dashboard built from the metrics (see [Grafana JSON on GitHub](https://raw.githubusercontent.com/binwiederhier/ntfy/main/examples/grafana-dashboard/ntfy-grafana.json)): - -
- -
ntfy Grafana dashboard
-
- -## Profiling -ntfy can expose Go's [net/http/pprof](https://pkg.go.dev/net/http/pprof) endpoints to support profiling of the ntfy server. -If enabled, ntfy will listen on a dedicated listen IP/port, which can be accessed via the web browser on `http://:/debug/pprof/`. -This can be helpful to expose bottlenecks, and visualize call flows. To enable, simply set the `profile-listen-http` config option. - ## Logging & debugging By default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format. @@ -1259,15 +1165,10 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | | `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` | | `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | -| `twilio-account` | `NTFY_TWILIO_ACCOUNT` | *string* | - | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 | -| `twilio-auth-token` | `NTFY_TWILIO_AUTH_TOKEN` | *string* | - | Twilio auth token, e.g. affebeef258625862586258625862586 | -| `twilio-phone-number` | `NTFY_TWILIO_PHONE_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 | -| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 | | `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | | `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | | `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | | `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers | -| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth | | `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. | | `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | | `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | @@ -1277,14 +1178,12 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | | `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | -| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting | -| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | +| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) | | `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | | `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | | `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) | | `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | | `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | -| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h. The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k. @@ -1368,7 +1267,6 @@ OPTIONS: --behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] --stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY] --stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY] - --billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT] --help, -h show help (default: false) ``` diff --git a/docs/develop.md b/docs/develop.md index baab3f3..a53c503 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -163,15 +163,6 @@ $ make release-snapshot During development, you may want to be more picky and build only certain things. Here are a few examples. -### Build a Docker image only for Linux - -This is useful to test the final build with web app, docs, and server without any dependencies locally - -``` shell -$ make docker-dev -$ docker run --rm -p 80:80 binwiederhier/ntfy:dev serve -``` - ### Build the ntfy binary To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets: diff --git a/docs/emojis.md b/docs/emojis.md index fa01bb4..594a6ec 100644 --- a/docs/emojis.md +++ b/docs/emojis.md @@ -6,1826 +6,1826 @@ You can [tag messages](../publish/#tags-emojis) with emojis 🥳 🎉 and other converted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the [tagging and emojis page](../publish/#tags-emojis). - +
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + -
TagEmoji
grinning😀
smiley😃
smile😄
grin😁
laughing😆
sweat_smile😅
rofl🤣
joy😂
slightly_smiling_face🙂
upside_down_face🙃
wink😉
blush😊
innocent😇
smiling_face_with_three_hearts🥰
heart_eyes😍
star_struck🤩
kissing_heart😘
kissing😗
relaxed☺️
kissing_closed_eyes😚
kissing_smiling_eyes😙
smiling_face_with_tear🥲
yum😋
stuck_out_tongue😛
stuck_out_tongue_winking_eye😜
zany_face🤪
stuck_out_tongue_closed_eyes😝
money_mouth_face🤑
hugs🤗
hand_over_mouth🤭
shushing_face🤫
thinking🤔
zipper_mouth_face🤐
raised_eyebrow🤨
neutral_face😐
expressionless😑
no_mouth😶
face_in_clouds😶‍🌫️
smirk😏
unamused😒
roll_eyes🙄
grimacing😬
face_exhaling😮‍💨
lying_face🤥
relieved😌
pensive😔
sleepy😪
drooling_face🤤
sleeping😴
mask😷
face_with_thermometer🤒
face_with_head_bandage🤕
nauseated_face🤢
vomiting_face🤮
sneezing_face🤧
hot_face🥵
cold_face🥶
woozy_face🥴
dizzy_face😵
face_with_spiral_eyes😵‍💫
exploding_head🤯
cowboy_hat_face🤠
partying_face🥳
disguised_face🥸
sunglasses😎
nerd_face🤓
monocle_face🧐
confused😕
worried😟
slightly_frowning_face🙁
frowning_face☹️
open_mouth😮
hushed😯
astonished😲
flushed😳
pleading_face🥺
frowning😦
anguished😧
fearful😨
cold_sweat😰
disappointed_relieved😥
cry😢
sob😭
scream😱
confounded😖
persevere😣
disappointed😞
sweat😓
weary😩
tired_face😫
yawning_face🥱
triumph😤
rage😡
angry😠
cursing_face🤬
smiling_imp😈
imp👿
skull💀
skull_and_crossbones☠️
hankey💩
clown_face🤡
japanese_ogre👹
japanese_goblin👺
ghost👻
alien👽
space_invader👾
robot🤖
smiley_cat😺
smile_cat😸
joy_cat😹
heart_eyes_cat😻
smirk_cat😼
kissing_cat😽
scream_cat🙀
crying_cat_face😿
pouting_cat😾
see_no_evil🙈
hear_no_evil🙉
speak_no_evil🙊
kiss💋
love_letter💌
cupid💘
gift_heart💝
sparkling_heart💖
heartpulse💗
heartbeat💓
revolving_hearts💞
two_hearts💕
heart_decoration💟
heavy_heart_exclamation❣️
broken_heart💔
heart_on_fire❤️‍🔥
mending_heart❤️‍🩹
heart❤️
orange_heart🧡
yellow_heart💛
green_heart💚
blue_heart💙
purple_heart💜
brown_heart🤎
black_heart🖤
white_heart🤍
100💯
anger💢
boom💥
dizzy💫
sweat_drops💦
dash💨
hole🕳️
bomb💣
speech_balloon💬
eye_speech_bubble👁️‍🗨️
left_speech_bubble🗨️
right_anger_bubble🗯️
thought_balloon💭
zzz💤
wave👋
raised_back_of_hand🤚
raised_hand_with_fingers_splayed🖐️
hand
vulcan_salute🖖
ok_hand👌
pinched_fingers🤌
pinching_hand🤏
v✌️
crossed_fingers🤞
love_you_gesture🤟
metal🤘
call_me_hand🤙
point_left👈
point_right👉
point_up_2👆
middle_finger🖕
point_down👇
point_up☝️
+1👍
-1👎
fist_raised
fist_oncoming👊
fist_left🤛
fist_right🤜
clap👏
raised_hands🙌
open_hands👐
palms_up_together🤲
handshake🤝
pray🙏
writing_hand✍️
nail_care💅
selfie🤳
muscle💪
mechanical_arm🦾
mechanical_leg🦿
leg🦵
foot🦶
ear👂
ear_with_hearing_aid🦻
nose👃
brain🧠
anatomical_heart🫀
lungs🫁
tooth🦷
bone🦴
eyes👀
eye👁️
tongue👅
lips👄
baby👶
child🧒
boy👦
girl👧
adult🧑
blond_haired_person👱
man👨
bearded_person🧔
man_beard🧔‍♂️
woman_beard🧔‍♀️
red_haired_man👨‍🦰
curly_haired_man👨‍🦱
white_haired_man👨‍🦳
bald_man👨‍🦲
woman👩
red_haired_woman👩‍🦰
person_red_hair🧑‍🦰
curly_haired_woman👩‍🦱
person_curly_hair🧑‍🦱
white_haired_woman👩‍🦳
person_white_hair🧑‍🦳
bald_woman👩‍🦲
person_bald🧑‍🦲
blond_haired_woman👱‍♀️
blond_haired_man👱‍♂️
older_adult🧓
older_man👴
older_woman👵
frowning_person🙍
frowning_man🙍‍♂️
frowning_woman🙍‍♀️
pouting_face🙎
pouting_man🙎‍♂️
pouting_woman🙎‍♀️
no_good🙅
no_good_man🙅‍♂️
no_good_woman🙅‍♀️
ok_person🙆
ok_man🙆‍♂️
ok_woman🙆‍♀️
tipping_hand_person💁
tipping_hand_man💁‍♂️
tipping_hand_woman💁‍♀️
raising_hand🙋
raising_hand_man🙋‍♂️
raising_hand_woman🙋‍♀️
deaf_person🧏
deaf_man🧏‍♂️
deaf_woman🧏‍♀️
bow🙇
bowing_man🙇‍♂️
bowing_woman🙇‍♀️
facepalm🤦
man_facepalming🤦‍♂️
woman_facepalming🤦‍♀️
shrug🤷
man_shrugging🤷‍♂️
woman_shrugging🤷‍♀️
health_worker🧑‍⚕️
man_health_worker👨‍⚕️
woman_health_worker👩‍⚕️
student🧑‍🎓
man_student👨‍🎓
woman_student👩‍🎓
teacher🧑‍🏫
man_teacher👨‍🏫
woman_teacher👩‍🏫
judge🧑‍⚖️
man_judge👨‍⚖️
woman_judge👩‍⚖️
farmer🧑‍🌾
man_farmer👨‍🌾
woman_farmer👩‍🌾
cook🧑‍🍳
man_cook👨‍🍳
woman_cook👩‍🍳
mechanic🧑‍🔧
man_mechanic👨‍🔧
woman_mechanic👩‍🔧
factory_worker🧑‍🏭
man_factory_worker👨‍🏭
woman_factory_worker👩‍🏭
office_worker🧑‍💼
man_office_worker👨‍💼
woman_office_worker👩‍💼
scientist🧑‍🔬
man_scientist👨‍🔬
woman_scientist👩‍🔬
technologist🧑‍💻
man_technologist👨‍💻
woman_technologist👩‍💻
singer🧑‍🎤
man_singer👨‍🎤
woman_singer👩‍🎤
artist🧑‍🎨
man_artist👨‍🎨
woman_artist👩‍🎨
pilot🧑‍✈️
man_pilot👨‍✈️
woman_pilot👩‍✈️
astronaut🧑‍🚀
man_astronaut👨‍🚀
woman_astronaut👩‍🚀
firefighter🧑‍🚒
man_firefighter👨‍🚒
woman_firefighter👩‍🚒
police_officer👮
policeman👮‍♂️
policewoman👮‍♀️
detective🕵️
male_detective🕵️‍♂️
female_detective🕵️‍♀️
guard💂
guardsman💂‍♂️
guardswoman💂‍♀️
ninja🥷
construction_worker👷
construction_worker_man👷‍♂️
construction_worker_woman👷‍♀️
prince🤴
princess👸
person_with_turban👳
man_with_turban👳‍♂️
woman_with_turban👳‍♀️
man_with_gua_pi_mao👲
woman_with_headscarf🧕
person_in_tuxedo🤵
man_in_tuxedo🤵‍♂️
woman_in_tuxedo🤵‍♀️
person_with_veil👰
man_with_veil👰‍♂️
woman_with_veil👰‍♀️
pregnant_woman🤰
breast_feeding🤱
woman_feeding_baby👩‍🍼
man_feeding_baby👨‍🍼
person_feeding_baby🧑‍🍼
angel👼
santa🎅
mrs_claus🤶
mx_claus🧑‍🎄
superhero🦸
superhero_man🦸‍♂️
superhero_woman🦸‍♀️
supervillain🦹
supervillain_man🦹‍♂️
supervillain_woman🦹‍♀️
mage🧙
mage_man🧙‍♂️
mage_woman🧙‍♀️
fairy🧚
fairy_man🧚‍♂️
fairy_woman🧚‍♀️
vampire🧛
vampire_man🧛‍♂️
vampire_woman🧛‍♀️
merperson🧜
merman🧜‍♂️
mermaid🧜‍♀️
elf🧝
elf_man🧝‍♂️
elf_woman🧝‍♀️
genie🧞
genie_man🧞‍♂️
genie_woman🧞‍♀️
zombie🧟
zombie_man🧟‍♂️
zombie_woman🧟‍♀️
massage💆
massage_man💆‍♂️
massage_woman💆‍♀️
haircut💇
haircut_man💇‍♂️
haircut_woman💇‍♀️
walking🚶
walking_man🚶‍♂️
walking_woman🚶‍♀️
standing_person🧍
standing_man🧍‍♂️
standing_woman🧍‍♀️
kneeling_person🧎
kneeling_man🧎‍♂️
kneeling_woman🧎‍♀️
person_with_probing_cane🧑‍🦯
man_with_probing_cane👨‍🦯
woman_with_probing_cane👩‍🦯
person_in_motorized_wheelchair🧑‍🦼
man_in_motorized_wheelchair👨‍🦼
woman_in_motorized_wheelchair👩‍🦼
person_in_manual_wheelchair🧑‍🦽
man_in_manual_wheelchair👨‍🦽
woman_in_manual_wheelchair👩‍🦽
runner🏃
running_man🏃‍♂️
running_woman🏃‍♀️
woman_dancing💃
man_dancing🕺
business_suit_levitating🕴️
dancers👯
dancing_men👯‍♂️
dancing_women👯‍♀️
sauna_person🧖
sauna_man🧖‍♂️
sauna_woman🧖‍♀️
climbing🧗
climbing_man🧗‍♂️
climbing_woman🧗‍♀️
person_fencing🤺
horse_racing🏇
skier⛷️
snowboarder🏂
golfing🏌️
golfing_man🏌️‍♂️
golfing_woman🏌️‍♀️
surfer🏄
surfing_man🏄‍♂️
surfing_woman🏄‍♀️
rowboat🚣
rowing_man🚣‍♂️
rowing_woman🚣‍♀️
swimmer🏊
swimming_man🏊‍♂️
swimming_woman🏊‍♀️
bouncing_ball_person⛹️
bouncing_ball_man⛹️‍♂️
bouncing_ball_woman⛹️‍♀️
weight_lifting🏋️
weight_lifting_man🏋️‍♂️
weight_lifting_woman🏋️‍♀️
bicyclist🚴
biking_man🚴‍♂️
biking_woman🚴‍♀️
mountain_bicyclist🚵
mountain_biking_man🚵‍♂️
mountain_biking_woman🚵‍♀️
cartwheeling🤸
man_cartwheeling🤸‍♂️
woman_cartwheeling🤸‍♀️
wrestling🤼
men_wrestling🤼‍♂️
women_wrestling🤼‍♀️
water_polo🤽
man_playing_water_polo🤽‍♂️
woman_playing_water_polo🤽‍♀️
handball_person🤾
man_playing_handball🤾‍♂️
woman_playing_handball🤾‍♀️
juggling_person🤹
man_juggling🤹‍♂️
woman_juggling🤹‍♀️
lotus_position🧘
lotus_position_man🧘‍♂️
lotus_position_woman🧘‍♀️
bath🛀
sleeping_bed🛌
people_holding_hands🧑‍🤝‍🧑
two_women_holding_hands👭
couple👫
two_men_holding_hands👬
couplekiss💏
couplekiss_man_woman👩‍❤️‍💋‍👨
couplekiss_man_man👨‍❤️‍💋‍👨
couplekiss_woman_woman👩‍❤️‍💋‍👩
couple_with_heart💑
couple_with_heart_woman_man👩‍❤️‍👨
couple_with_heart_man_man👨‍❤️‍👨
couple_with_heart_woman_woman👩‍❤️‍👩
family👪
family_man_woman_boy👨‍👩‍👦
family_man_woman_girl👨‍👩‍👧
family_man_woman_girl_boy👨‍👩‍👧‍👦
family_man_woman_boy_boy👨‍👩‍👦‍👦
family_man_woman_girl_girl👨‍👩‍👧‍👧
family_man_man_boy👨‍👨‍👦
family_man_man_girl👨‍👨‍👧
family_man_man_girl_boy👨‍👨‍👧‍👦
family_man_man_boy_boy👨‍👨‍👦‍👦
family_man_man_girl_girl👨‍👨‍👧‍👧
family_woman_woman_boy👩‍👩‍👦
family_woman_woman_girl👩‍👩‍👧
family_woman_woman_girl_boy👩‍👩‍👧‍👦
family_woman_woman_boy_boy👩‍👩‍👦‍👦
family_woman_woman_girl_girl👩‍👩‍👧‍👧
family_man_boy👨‍👦
family_man_boy_boy👨‍👦‍👦
family_man_girl👨‍👧
family_man_girl_boy👨‍👧‍👦
family_man_girl_girl👨‍👧‍👧
family_woman_boy👩‍👦
family_woman_boy_boy👩‍👦‍👦
family_woman_girl👩‍👧
family_woman_girl_boy👩‍👧‍👦
family_woman_girl_girl👩‍👧‍👧
speaking_head🗣️
bust_in_silhouette👤
busts_in_silhouette👥
people_hugging🫂
footprints👣
monkey_face🐵
monkey🐒
gorilla🦍
orangutan🦧
dog🐶
dog2🐕
guide_dog🦮
service_dog🐕‍🦺
poodle🐩
wolf🐺
fox_face🦊
raccoon🦝
cat🐱
cat2🐈
black_cat🐈‍⬛
lion🦁
tiger🐯
tiger2🐅
leopard🐆
horse🐴
racehorse🐎
unicorn🦄
zebra🦓
deer🦌
bison🦬
cow🐮
ox🐂
water_buffalo🐃
cow2🐄
pig🐷
pig2🐖
boar🐗
pig_nose🐽
ram🐏
sheep🐑
goat🐐
dromedary_camel🐪
camel🐫
llama🦙
giraffe🦒
elephant🐘
mammoth🦣
rhinoceros🦏
hippopotamus🦛
mouse🐭
mouse2🐁
rat🐀
hamster🐹
rabbit🐰
rabbit2🐇
chipmunk🐿️
beaver🦫
hedgehog🦔
bat🦇
bear🐻
polar_bear🐻‍❄️
koala🐨
panda_face🐼
sloth🦥
otter🦦
skunk🦨
kangaroo🦘
badger🦡
feet🐾
turkey🦃
chicken🐔
rooster🐓
hatching_chick🐣
baby_chick🐤
hatched_chick🐥
bird🐦
penguin🐧
dove🕊️
eagle🦅
duck🦆
swan🦢
owl🦉
dodo🦤
feather🪶
flamingo🦩
peacock🦚
parrot🦜
frog🐸
crocodile🐊
turtle🐢
lizard🦎
snake🐍
dragon_face🐲
dragon🐉
sauropod🦕
t-rex🦖
whale🐳
whale2🐋
dolphin🐬
seal🦭
fish🐟
tropical_fish🐠
blowfish🐡
shark🦈
octopus🐙
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagEmoji
grinning😀
smiley😃
smile😄
grin😁
laughing😆
sweat_smile😅
rofl🤣
joy😂
slightly_smiling_face🙂
upside_down_face🙃
wink😉
blush😊
innocent😇
smiling_face_with_three_hearts🥰
heart_eyes😍
star_struck🤩
kissing_heart😘
kissing😗
relaxed☺️
kissing_closed_eyes😚
kissing_smiling_eyes😙
smiling_face_with_tear🥲
yum😋
stuck_out_tongue😛
stuck_out_tongue_winking_eye😜
zany_face🤪
stuck_out_tongue_closed_eyes😝
money_mouth_face🤑
hugs🤗
hand_over_mouth🤭
shushing_face🤫
thinking🤔
zipper_mouth_face🤐
raised_eyebrow🤨
neutral_face😐
expressionless😑
no_mouth😶
face_in_clouds😶‍🌫️
smirk😏
unamused😒
roll_eyes🙄
grimacing😬
face_exhaling😮‍💨
lying_face🤥
relieved😌
pensive😔
sleepy😪
drooling_face🤤
sleeping😴
mask😷
face_with_thermometer🤒
face_with_head_bandage🤕
nauseated_face🤢
vomiting_face🤮
sneezing_face🤧
hot_face🥵
cold_face🥶
woozy_face🥴
dizzy_face😵
face_with_spiral_eyes😵‍💫
exploding_head🤯
cowboy_hat_face🤠
partying_face🥳
disguised_face🥸
sunglasses😎
nerd_face🤓
monocle_face🧐
confused😕
worried😟
slightly_frowning_face🙁
frowning_face☹️
open_mouth😮
hushed😯
astonished😲
flushed😳
pleading_face🥺
frowning😦
anguished😧
fearful😨
cold_sweat😰
disappointed_relieved😥
cry😢
sob😭
scream😱
confounded😖
persevere😣
disappointed😞
sweat😓
weary😩
tired_face😫
yawning_face🥱
triumph😤
rage😡
angry😠
cursing_face🤬
smiling_imp😈
imp👿
skull💀
skull_and_crossbones☠️
hankey💩
clown_face🤡
japanese_ogre👹
japanese_goblin👺
ghost👻
alien👽
space_invader👾
robot🤖
smiley_cat😺
smile_cat😸
joy_cat😹
heart_eyes_cat😻
smirk_cat😼
kissing_cat😽
scream_cat🙀
crying_cat_face😿
pouting_cat😾
see_no_evil🙈
hear_no_evil🙉
speak_no_evil🙊
kiss💋
love_letter💌
cupid💘
gift_heart💝
sparkling_heart💖
heartpulse💗
heartbeat💓
revolving_hearts💞
two_hearts💕
heart_decoration💟
heavy_heart_exclamation❣️
broken_heart💔
heart_on_fire❤️‍🔥
mending_heart❤️‍🩹
heart❤️
orange_heart🧡
yellow_heart💛
green_heart💚
blue_heart💙
purple_heart💜
brown_heart🤎
black_heart🖤
white_heart🤍
100💯
anger💢
boom💥
dizzy💫
sweat_drops💦
dash💨
hole🕳️
bomb💣
speech_balloon💬
eye_speech_bubble👁️‍🗨️
left_speech_bubble🗨️
right_anger_bubble🗯️
thought_balloon💭
zzz💤
wave👋
raised_back_of_hand🤚
raised_hand_with_fingers_splayed🖐️
hand
vulcan_salute🖖
ok_hand👌
pinched_fingers🤌
pinching_hand🤏
v✌️
crossed_fingers🤞
love_you_gesture🤟
metal🤘
call_me_hand🤙
point_left👈
point_right👉
point_up_2👆
middle_finger🖕
point_down👇
point_up☝️
+1👍
-1👎
fist_raised
fist_oncoming👊
fist_left🤛
fist_right🤜
clap👏
raised_hands🙌
open_hands👐
palms_up_together🤲
handshake🤝
pray🙏
writing_hand✍️
nail_care💅
selfie🤳
muscle💪
mechanical_arm🦾
mechanical_leg🦿
leg🦵
foot🦶
ear👂
ear_with_hearing_aid🦻
nose👃
brain🧠
anatomical_heart🫀
lungs🫁
tooth🦷
bone🦴
eyes👀
eye👁️
tongue👅
lips👄
baby👶
child🧒
boy👦
girl👧
adult🧑
blond_haired_person👱
man👨
bearded_person🧔
man_beard🧔‍♂️
woman_beard🧔‍♀️
red_haired_man👨‍🦰
curly_haired_man👨‍🦱
white_haired_man👨‍🦳
bald_man👨‍🦲
woman👩
red_haired_woman👩‍🦰
person_red_hair🧑‍🦰
curly_haired_woman👩‍🦱
person_curly_hair🧑‍🦱
white_haired_woman👩‍🦳
person_white_hair🧑‍🦳
bald_woman👩‍🦲
person_bald🧑‍🦲
blond_haired_woman👱‍♀️
blond_haired_man👱‍♂️
older_adult🧓
older_man👴
older_woman👵
frowning_person🙍
frowning_man🙍‍♂️
frowning_woman🙍‍♀️
pouting_face🙎
pouting_man🙎‍♂️
pouting_woman🙎‍♀️
no_good🙅
no_good_man🙅‍♂️
no_good_woman🙅‍♀️
ok_person🙆
ok_man🙆‍♂️
ok_woman🙆‍♀️
tipping_hand_person💁
tipping_hand_man💁‍♂️
tipping_hand_woman💁‍♀️
raising_hand🙋
raising_hand_man🙋‍♂️
raising_hand_woman🙋‍♀️
deaf_person🧏
deaf_man🧏‍♂️
deaf_woman🧏‍♀️
bow🙇
bowing_man🙇‍♂️
bowing_woman🙇‍♀️
facepalm🤦
man_facepalming🤦‍♂️
woman_facepalming🤦‍♀️
shrug🤷
man_shrugging🤷‍♂️
woman_shrugging🤷‍♀️
health_worker🧑‍⚕️
man_health_worker👨‍⚕️
woman_health_worker👩‍⚕️
student🧑‍🎓
man_student👨‍🎓
woman_student👩‍🎓
teacher🧑‍🏫
man_teacher👨‍🏫
woman_teacher👩‍🏫
judge🧑‍⚖️
man_judge👨‍⚖️
woman_judge👩‍⚖️
farmer🧑‍🌾
man_farmer👨‍🌾
woman_farmer👩‍🌾
cook🧑‍🍳
man_cook👨‍🍳
woman_cook👩‍🍳
mechanic🧑‍🔧
man_mechanic👨‍🔧
woman_mechanic👩‍🔧
factory_worker🧑‍🏭
man_factory_worker👨‍🏭
woman_factory_worker👩‍🏭
office_worker🧑‍💼
man_office_worker👨‍💼
woman_office_worker👩‍💼
scientist🧑‍🔬
man_scientist👨‍🔬
woman_scientist👩‍🔬
technologist🧑‍💻
man_technologist👨‍💻
woman_technologist👩‍💻
singer🧑‍🎤
man_singer👨‍🎤
woman_singer👩‍🎤
artist🧑‍🎨
man_artist👨‍🎨
woman_artist👩‍🎨
pilot🧑‍✈️
man_pilot👨‍✈️
woman_pilot👩‍✈️
astronaut🧑‍🚀
man_astronaut👨‍🚀
woman_astronaut👩‍🚀
firefighter🧑‍🚒
man_firefighter👨‍🚒
woman_firefighter👩‍🚒
police_officer👮
policeman👮‍♂️
policewoman👮‍♀️
detective🕵️
male_detective🕵️‍♂️
female_detective🕵️‍♀️
guard💂
guardsman💂‍♂️
guardswoman💂‍♀️
ninja🥷
construction_worker👷
construction_worker_man👷‍♂️
construction_worker_woman👷‍♀️
prince🤴
princess👸
person_with_turban👳
man_with_turban👳‍♂️
woman_with_turban👳‍♀️
man_with_gua_pi_mao👲
woman_with_headscarf🧕
person_in_tuxedo🤵
man_in_tuxedo🤵‍♂️
woman_in_tuxedo🤵‍♀️
person_with_veil👰
man_with_veil👰‍♂️
woman_with_veil👰‍♀️
pregnant_woman🤰
breast_feeding🤱
woman_feeding_baby👩‍🍼
man_feeding_baby👨‍🍼
person_feeding_baby🧑‍🍼
angel👼
santa🎅
mrs_claus🤶
mx_claus🧑‍🎄
superhero🦸
superhero_man🦸‍♂️
superhero_woman🦸‍♀️
supervillain🦹
supervillain_man🦹‍♂️
supervillain_woman🦹‍♀️
mage🧙
mage_man🧙‍♂️
mage_woman🧙‍♀️
fairy🧚
fairy_man🧚‍♂️
fairy_woman🧚‍♀️
vampire🧛
vampire_man🧛‍♂️
vampire_woman🧛‍♀️
merperson🧜
merman🧜‍♂️
mermaid🧜‍♀️
elf🧝
elf_man🧝‍♂️
elf_woman🧝‍♀️
genie🧞
genie_man🧞‍♂️
genie_woman🧞‍♀️
zombie🧟
zombie_man🧟‍♂️
zombie_woman🧟‍♀️
massage💆
massage_man💆‍♂️
massage_woman💆‍♀️
haircut💇
haircut_man💇‍♂️
haircut_woman💇‍♀️
walking🚶
walking_man🚶‍♂️
walking_woman🚶‍♀️
standing_person🧍
standing_man🧍‍♂️
standing_woman🧍‍♀️
kneeling_person🧎
kneeling_man🧎‍♂️
kneeling_woman🧎‍♀️
person_with_probing_cane🧑‍🦯
man_with_probing_cane👨‍🦯
woman_with_probing_cane👩‍🦯
person_in_motorized_wheelchair🧑‍🦼
man_in_motorized_wheelchair👨‍🦼
woman_in_motorized_wheelchair👩‍🦼
person_in_manual_wheelchair🧑‍🦽
man_in_manual_wheelchair👨‍🦽
woman_in_manual_wheelchair👩‍🦽
runner🏃
running_man🏃‍♂️
running_woman🏃‍♀️
woman_dancing💃
man_dancing🕺
business_suit_levitating🕴️
dancers👯
dancing_men👯‍♂️
dancing_women👯‍♀️
sauna_person🧖
sauna_man🧖‍♂️
sauna_woman🧖‍♀️
climbing🧗
climbing_man🧗‍♂️
climbing_woman🧗‍♀️
person_fencing🤺
horse_racing🏇
skier⛷️
snowboarder🏂
golfing🏌️
golfing_man🏌️‍♂️
golfing_woman🏌️‍♀️
surfer🏄
surfing_man🏄‍♂️
surfing_woman🏄‍♀️
rowboat🚣
rowing_man🚣‍♂️
rowing_woman🚣‍♀️
swimmer🏊
swimming_man🏊‍♂️
swimming_woman🏊‍♀️
bouncing_ball_person⛹️
bouncing_ball_man⛹️‍♂️
bouncing_ball_woman⛹️‍♀️
weight_lifting🏋️
weight_lifting_man🏋️‍♂️
weight_lifting_woman🏋️‍♀️
bicyclist🚴
biking_man🚴‍♂️
biking_woman🚴‍♀️
mountain_bicyclist🚵
mountain_biking_man🚵‍♂️
mountain_biking_woman🚵‍♀️
cartwheeling🤸
man_cartwheeling🤸‍♂️
woman_cartwheeling🤸‍♀️
wrestling🤼
men_wrestling🤼‍♂️
women_wrestling🤼‍♀️
water_polo🤽
man_playing_water_polo🤽‍♂️
woman_playing_water_polo🤽‍♀️
handball_person🤾
man_playing_handball🤾‍♂️
woman_playing_handball🤾‍♀️
juggling_person🤹
man_juggling🤹‍♂️
woman_juggling🤹‍♀️
lotus_position🧘
lotus_position_man🧘‍♂️
lotus_position_woman🧘‍♀️
bath🛀
sleeping_bed🛌
people_holding_hands🧑‍🤝‍🧑
two_women_holding_hands👭
couple👫
two_men_holding_hands👬
couplekiss💏
couplekiss_man_woman👩‍❤️‍💋‍👨
couplekiss_man_man👨‍❤️‍💋‍👨
couplekiss_woman_woman👩‍❤️‍💋‍👩
couple_with_heart💑
couple_with_heart_woman_man👩‍❤️‍👨
couple_with_heart_man_man👨‍❤️‍👨
couple_with_heart_woman_woman👩‍❤️‍👩
family👪
family_man_woman_boy👨‍👩‍👦
family_man_woman_girl👨‍👩‍👧
family_man_woman_girl_boy👨‍👩‍👧‍👦
family_man_woman_boy_boy👨‍👩‍👦‍👦
family_man_woman_girl_girl👨‍👩‍👧‍👧
family_man_man_boy👨‍👨‍👦
family_man_man_girl👨‍👨‍👧
family_man_man_girl_boy👨‍👨‍👧‍👦
family_man_man_boy_boy👨‍👨‍👦‍👦
family_man_man_girl_girl👨‍👨‍👧‍👧
family_woman_woman_boy👩‍👩‍👦
family_woman_woman_girl👩‍👩‍👧
family_woman_woman_girl_boy👩‍👩‍👧‍👦
family_woman_woman_boy_boy👩‍👩‍👦‍👦
family_woman_woman_girl_girl👩‍👩‍👧‍👧
family_man_boy👨‍👦
family_man_boy_boy👨‍👦‍👦
family_man_girl👨‍👧
family_man_girl_boy👨‍👧‍👦
family_man_girl_girl👨‍👧‍👧
family_woman_boy👩‍👦
family_woman_boy_boy👩‍👦‍👦
family_woman_girl👩‍👧
family_woman_girl_boy👩‍👧‍👦
family_woman_girl_girl👩‍👧‍👧
speaking_head🗣️
bust_in_silhouette👤
busts_in_silhouette👥
people_hugging🫂
footprints👣
monkey_face🐵
monkey🐒
gorilla🦍
orangutan🦧
dog🐶
dog2🐕
guide_dog🦮
service_dog🐕‍🦺
poodle🐩
wolf🐺
fox_face🦊
raccoon🦝
cat🐱
cat2🐈
black_cat🐈‍⬛
lion🦁
tiger🐯
tiger2🐅
leopard🐆
horse🐴
racehorse🐎
unicorn🦄
zebra🦓
deer🦌
bison🦬
cow🐮
ox🐂
water_buffalo🐃
cow2🐄
pig🐷
pig2🐖
boar🐗
pig_nose🐽
ram🐏
sheep🐑
goat🐐
dromedary_camel🐪
camel🐫
llama🦙
giraffe🦒
elephant🐘
mammoth🦣
rhinoceros🦏
hippopotamus🦛
mouse🐭
mouse2🐁
rat🐀
hamster🐹
rabbit🐰
rabbit2🐇
chipmunk🐿️
beaver🦫
hedgehog🦔
bat🦇
bear🐻
polar_bear🐻‍❄️
koala🐨
panda_face🐼
sloth🦥
otter🦦
skunk🦨
kangaroo🦘
badger🦡
feet🐾
turkey🦃
chicken🐔
rooster🐓
hatching_chick🐣
baby_chick🐤
hatched_chick🐥
bird🐦
penguin🐧
dove🕊️
eagle🦅
duck🦆
swan🦢
owl🦉
dodo🦤
feather🪶
flamingo🦩
peacock🦚
parrot🦜
frog🐸
crocodile🐊
turtle🐢
lizard🦎
snake🐍
dragon_face🐲
dragon🐉
sauropod🦕
t-rex🦖
whale🐳
whale2🐋
dolphin🐬
seal🦭
fish🐟
tropical_fish🐠
blowfish🐡
shark🦈
octopus🐙
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + -
TagEmoji
octopus🐙
shell🐚
snail🐌
butterfly🦋
bug🐛
ant🐜
bee🐝
beetle🪲
lady_beetle🐞
cricket🦗
cockroach🪳
spider🕷️
spider_web🕸️
scorpion🦂
mosquito🦟
fly🪰
worm🪱
microbe🦠
bouquet💐
cherry_blossom🌸
white_flower💮
rosette🏵️
rose🌹
wilted_flower🥀
hibiscus🌺
sunflower🌻
blossom🌼
tulip🌷
seedling🌱
potted_plant🪴
evergreen_tree🌲
deciduous_tree🌳
palm_tree🌴
cactus🌵
ear_of_rice🌾
herb🌿
shamrock☘️
four_leaf_clover🍀
maple_leaf🍁
fallen_leaf🍂
leaves🍃
grapes🍇
melon🍈
watermelon🍉
tangerine🍊
lemon🍋
banana🍌
pineapple🍍
mango🥭
apple🍎
green_apple🍏
pear🍐
peach🍑
cherries🍒
strawberry🍓
blueberries🫐
kiwi_fruit🥝
tomato🍅
olive🫒
coconut🥥
avocado🥑
eggplant🍆
potato🥔
carrot🥕
corn🌽
hot_pepper🌶️
bell_pepper🫑
cucumber🥒
leafy_green🥬
broccoli🥦
garlic🧄
onion🧅
mushroom🍄
peanuts🥜
chestnut🌰
bread🍞
croissant🥐
baguette_bread🥖
flatbread🫓
pretzel🥨
bagel🥯
pancakes🥞
waffle🧇
cheese🧀
meat_on_bone🍖
poultry_leg🍗
cut_of_meat🥩
bacon🥓
hamburger🍔
fries🍟
pizza🍕
hotdog🌭
sandwich🥪
taco🌮
burrito🌯
tamale🫔
stuffed_flatbread🥙
falafel🧆
egg🥚
fried_egg🍳
shallow_pan_of_food🥘
stew🍲
fondue🫕
bowl_with_spoon🥣
green_salad🥗
popcorn🍿
butter🧈
salt🧂
canned_food🥫
bento🍱
rice_cracker🍘
rice_ball🍙
rice🍚
curry🍛
ramen🍜
spaghetti🍝
sweet_potato🍠
oden🍢
sushi🍣
fried_shrimp🍤
fish_cake🍥
moon_cake🥮
dango🍡
dumpling🥟
fortune_cookie🥠
takeout_box🥡
crab🦀
lobster🦞
shrimp🦐
squid🦑
oyster🦪
icecream🍦
shaved_ice🍧
ice_cream🍨
doughnut🍩
cookie🍪
birthday🎂
cake🍰
cupcake🧁
pie🥧
chocolate_bar🍫
candy🍬
lollipop🍭
custard🍮
honey_pot🍯
baby_bottle🍼
milk_glass🥛
coffee
teapot🫖
tea🍵
sake🍶
champagne🍾
wine_glass🍷
cocktail🍸
tropical_drink🍹
beer🍺
beers🍻
clinking_glasses🥂
tumbler_glass🥃
cup_with_straw🥤
bubble_tea🧋
beverage_box🧃
mate🧉
ice_cube🧊
chopsticks🥢
plate_with_cutlery🍽️
fork_and_knife🍴
spoon🥄
hocho🔪
amphora🏺
earth_africa🌍
earth_americas🌎
earth_asia🌏
globe_with_meridians🌐
world_map🗺️
japan🗾
compass🧭
mountain_snow🏔️
mountain⛰️
volcano🌋
mount_fuji🗻
camping🏕️
beach_umbrella🏖️
desert🏜️
desert_island🏝️
national_park🏞️
stadium🏟️
classical_building🏛️
building_construction🏗️
bricks🧱
rock🪨
wood🪵
hut🛖
houses🏘️
derelict_house🏚️
house🏠
house_with_garden🏡
office🏢
post_office🏣
european_post_office🏤
hospital🏥
bank🏦
hotel🏨
love_hotel🏩
convenience_store🏪
school🏫
department_store🏬
factory🏭
japanese_castle🏯
european_castle🏰
wedding💒
tokyo_tower🗼
statue_of_liberty🗽
church
mosque🕌
hindu_temple🛕
synagogue🕍
shinto_shrine⛩️
kaaba🕋
fountain
tent
foggy🌁
night_with_stars🌃
cityscape🏙️
sunrise_over_mountains🌄
sunrise🌅
city_sunset🌆
city_sunrise🌇
bridge_at_night🌉
hotsprings♨️
carousel_horse🎠
ferris_wheel🎡
roller_coaster🎢
barber💈
circus_tent🎪
steam_locomotive🚂
railway_car🚃
bullettrain_side🚄
bullettrain_front🚅
train2🚆
metro🚇
light_rail🚈
station🚉
tram🚊
monorail🚝
mountain_railway🚞
train🚋
bus🚌
oncoming_bus🚍
trolleybus🚎
minibus🚐
ambulance🚑
fire_engine🚒
police_car🚓
oncoming_police_car🚔
taxi🚕
oncoming_taxi🚖
car🚗
oncoming_automobile🚘
blue_car🚙
pickup_truck🛻
truck🚚
articulated_lorry🚛
tractor🚜
racing_car🏎️
motorcycle🏍️
motor_scooter🛵
manual_wheelchair🦽
motorized_wheelchair🦼
auto_rickshaw🛺
bike🚲
kick_scooter🛴
skateboard🛹
roller_skate🛼
busstop🚏
motorway🛣️
railway_track🛤️
oil_drum🛢️
fuelpump
rotating_light🚨
traffic_light🚥
vertical_traffic_light🚦
stop_sign🛑
construction🚧
anchor
boat
canoe🛶
speedboat🚤
passenger_ship🛳️
ferry⛴️
motor_boat🛥️
ship🚢
airplane✈️
small_airplane🛩️
flight_departure🛫
flight_arrival🛬
parachute🪂
seat💺
helicopter🚁
suspension_railway🚟
mountain_cableway🚠
aerial_tramway🚡
artificial_satellite🛰️
rocket🚀
flying_saucer🛸
bellhop_bell🛎️
luggage🧳
hourglass
hourglass_flowing_sand
watch
alarm_clock
stopwatch⏱️
timer_clock⏲️
mantelpiece_clock🕰️
clock12🕛
clock1230🕧
clock1🕐
clock130🕜
clock2🕑
clock230🕝
clock3🕒
clock330🕞
clock4🕓
clock430🕟
clock5🕔
clock530🕠
clock6🕕
clock630🕡
clock7🕖
clock730🕢
clock8🕗
clock830🕣
clock9🕘
clock930🕤
clock10🕙
clock1030🕥
clock11🕚
clock1130🕦
new_moon🌑
waxing_crescent_moon🌒
first_quarter_moon🌓
moon🌔
full_moon🌕
waning_gibbous_moon🌖
last_quarter_moon🌗
waning_crescent_moon🌘
crescent_moon🌙
new_moon_with_face🌚
first_quarter_moon_with_face🌛
last_quarter_moon_with_face🌜
thermometer🌡️
sunny☀️
full_moon_with_face🌝
sun_with_face🌞
ringed_planet🪐
star
star2🌟
stars🌠
milky_way🌌
cloud☁️
partly_sunny
cloud_with_lightning_and_rain⛈️
sun_behind_small_cloud🌤️
sun_behind_large_cloud🌥️
sun_behind_rain_cloud🌦️
cloud_with_rain🌧️
cloud_with_snow🌨️
cloud_with_lightning🌩️
tornado🌪️
fog🌫️
wind_face🌬️
cyclone🌀
rainbow🌈
closed_umbrella🌂
open_umbrella☂️
umbrella
parasol_on_ground⛱️
zap
snowflake❄️
snowman_with_snow☃️
snowman
comet☄️
fire🔥
droplet💧
ocean🌊
jack_o_lantern🎃
christmas_tree🎄
fireworks🎆
sparkler🎇
firecracker🧨
sparkles
balloon🎈
tada🎉
confetti_ball🎊
tanabata_tree🎋
bamboo🎍
dolls🎎
flags🎏
wind_chime🎐
rice_scene🎑
red_envelope🧧
ribbon🎀
gift🎁
reminder_ribbon🎗️
tickets🎟️
ticket🎫
medal_military🎖️
trophy🏆
medal_sports🏅
1st_place_medal🥇
2nd_place_medal🥈
3rd_place_medal🥉
soccer
baseball
softball🥎
basketball🏀
volleyball🏐
football🏈
rugby_football🏉
tennis🎾
flying_disc🥏
bowling🎳
cricket_game🏏
field_hockey🏑
ice_hockey🏒
lacrosse🥍
ping_pong🏓
badminton🏸
boxing_glove🥊
martial_arts_uniform🥋
goal_net🥅
golf
ice_skate⛸️
fishing_pole_and_fish🎣
diving_mask🤿
running_shirt_with_sash🎽
ski🎿
sled🛷
curling_stone🥌
dart🎯
yo_yo🪀
kite🪁
8ball🎱
crystal_ball🔮
magic_wand🪄
nazar_amulet🧿
video_game🎮
joystick🕹️
slot_machine🎰
game_die🎲
jigsaw🧩
teddy_bear🧸
pinata🪅
nesting_dolls🪆
spades♠️
hearts♥️
diamonds♦️
clubs♣️
chess_pawn♟️
black_joker🃏
mahjong🀄
flower_playing_cards🎴
performing_arts🎭
framed_picture🖼️
art🎨
thread🧵
sewing_needle🪡
yarn🧶
knot🪢
eyeglasses👓
dark_sunglasses🕶️
goggles🥽
lab_coat🥼
safety_vest🦺
necktie👔
shirt👕
jeans👖
scarf🧣
gloves🧤
coat🧥
socks🧦
dress👗
kimono👘
sari🥻
one_piece_swimsuit🩱
swim_brief🩲
shorts🩳
bikini👙
womans_clothes👚
purse👛
handbag👜
pouch👝
shopping🛍️
school_satchel🎒
thong_sandal🩴
mans_shoe👞
athletic_shoe👟
hiking_boot🥾
flat_shoe🥿
high_heel👠
sandal👡
ballet_shoes🩰
boot👢
crown👑
womans_hat👒
tophat🎩
mortar_board🎓
billed_cap🧢
military_helmet🪖
rescue_worker_helmet⛑️
prayer_beads📿
lipstick💄
ring💍
gem💎
mute🔇
speaker🔈
sound🔉
loud_sound🔊
loudspeaker📢
mega📣
postal_horn📯
bell🔔
no_bell🔕
musical_score🎼
musical_note🎵
notes🎶
studio_microphone🎙️
level_slider🎚️
control_knobs🎛️
microphone🎤
headphones🎧
radio📻
saxophone🎷
accordion🪗
guitar🎸
musical_keyboard🎹
trumpet🎺
violin🎻
banjo🪕
drum🥁
long_drum🪘
iphone📱
calling📲
phone☎️
telephone_receiver📞
pager📟
fax📠
battery🔋
electric_plug🔌
computer💻
desktop_computer🖥️
printer🖨️
keyboard⌨️
computer_mouse🖱️
trackball🖲️
minidisc💽
floppy_disk💾
cd💿
dvd📀
abacus🧮
movie_camera🎥
film_strip🎞️
film_projector📽️
clapper🎬
tv📺
camera📷
camera_flash📸
video_camera📹
vhs📼
mag🔍
mag_right🔎
candle🕯️
bulb💡
flashlight🔦
izakaya_lantern🏮
diya_lamp🪔
notebook_with_decorative_cover📔
closed_book📕
book📖
green_book📗
blue_book📘
orange_book📙
books📚
notebook📓
ledger📒
page_with_curl📃
scroll📜
page_facing_up📄
newspaper📰
newspaper_roll🗞️
bookmark_tabs📑
bookmark🔖
label🏷️
moneybag💰
coin🪙
yen💴
dollar💵
euro💶
pound💷
money_with_wings💸
credit_card💳
receipt🧾
chart💹
envelope✉️
email📧
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagEmoji
octopus🐙
shell🐚
snail🐌
butterfly🦋
bug🐛
ant🐜
bee🐝
beetle🪲
lady_beetle🐞
cricket🦗
cockroach🪳
spider🕷️
spider_web🕸️
scorpion🦂
mosquito🦟
fly🪰
worm🪱
microbe🦠
bouquet💐
cherry_blossom🌸
white_flower💮
rosette🏵️
rose🌹
wilted_flower🥀
hibiscus🌺
sunflower🌻
blossom🌼
tulip🌷
seedling🌱
potted_plant🪴
evergreen_tree🌲
deciduous_tree🌳
palm_tree🌴
cactus🌵
ear_of_rice🌾
herb🌿
shamrock☘️
four_leaf_clover🍀
maple_leaf🍁
fallen_leaf🍂
leaves🍃
grapes🍇
melon🍈
watermelon🍉
tangerine🍊
lemon🍋
banana🍌
pineapple🍍
mango🥭
apple🍎
green_apple🍏
pear🍐
peach🍑
cherries🍒
strawberry🍓
blueberries🫐
kiwi_fruit🥝
tomato🍅
olive🫒
coconut🥥
avocado🥑
eggplant🍆
potato🥔
carrot🥕
corn🌽
hot_pepper🌶️
bell_pepper🫑
cucumber🥒
leafy_green🥬
broccoli🥦
garlic🧄
onion🧅
mushroom🍄
peanuts🥜
chestnut🌰
bread🍞
croissant🥐
baguette_bread🥖
flatbread🫓
pretzel🥨
bagel🥯
pancakes🥞
waffle🧇
cheese🧀
meat_on_bone🍖
poultry_leg🍗
cut_of_meat🥩
bacon🥓
hamburger🍔
fries🍟
pizza🍕
hotdog🌭
sandwich🥪
taco🌮
burrito🌯
tamale🫔
stuffed_flatbread🥙
falafel🧆
egg🥚
fried_egg🍳
shallow_pan_of_food🥘
stew🍲
fondue🫕
bowl_with_spoon🥣
green_salad🥗
popcorn🍿
butter🧈
salt🧂
canned_food🥫
bento🍱
rice_cracker🍘
rice_ball🍙
rice🍚
curry🍛
ramen🍜
spaghetti🍝
sweet_potato🍠
oden🍢
sushi🍣
fried_shrimp🍤
fish_cake🍥
moon_cake🥮
dango🍡
dumpling🥟
fortune_cookie🥠
takeout_box🥡
crab🦀
lobster🦞
shrimp🦐
squid🦑
oyster🦪
icecream🍦
shaved_ice🍧
ice_cream🍨
doughnut🍩
cookie🍪
birthday🎂
cake🍰
cupcake🧁
pie🥧
chocolate_bar🍫
candy🍬
lollipop🍭
custard🍮
honey_pot🍯
baby_bottle🍼
milk_glass🥛
coffee
teapot🫖
tea🍵
sake🍶
champagne🍾
wine_glass🍷
cocktail🍸
tropical_drink🍹
beer🍺
beers🍻
clinking_glasses🥂
tumbler_glass🥃
cup_with_straw🥤
bubble_tea🧋
beverage_box🧃
mate🧉
ice_cube🧊
chopsticks🥢
plate_with_cutlery🍽️
fork_and_knife🍴
spoon🥄
hocho🔪
amphora🏺
earth_africa🌍
earth_americas🌎
earth_asia🌏
globe_with_meridians🌐
world_map🗺️
japan🗾
compass🧭
mountain_snow🏔️
mountain⛰️
volcano🌋
mount_fuji🗻
camping🏕️
beach_umbrella🏖️
desert🏜️
desert_island🏝️
national_park🏞️
stadium🏟️
classical_building🏛️
building_construction🏗️
bricks🧱
rock🪨
wood🪵
hut🛖
houses🏘️
derelict_house🏚️
house🏠
house_with_garden🏡
office🏢
post_office🏣
european_post_office🏤
hospital🏥
bank🏦
hotel🏨
love_hotel🏩
convenience_store🏪
school🏫
department_store🏬
factory🏭
japanese_castle🏯
european_castle🏰
wedding💒
tokyo_tower🗼
statue_of_liberty🗽
church
mosque🕌
hindu_temple🛕
synagogue🕍
shinto_shrine⛩️
kaaba🕋
fountain
tent
foggy🌁
night_with_stars🌃
cityscape🏙️
sunrise_over_mountains🌄
sunrise🌅
city_sunset🌆
city_sunrise🌇
bridge_at_night🌉
hotsprings♨️
carousel_horse🎠
ferris_wheel🎡
roller_coaster🎢
barber💈
circus_tent🎪
steam_locomotive🚂
railway_car🚃
bullettrain_side🚄
bullettrain_front🚅
train2🚆
metro🚇
light_rail🚈
station🚉
tram🚊
monorail🚝
mountain_railway🚞
train🚋
bus🚌
oncoming_bus🚍
trolleybus🚎
minibus🚐
ambulance🚑
fire_engine🚒
police_car🚓
oncoming_police_car🚔
taxi🚕
oncoming_taxi🚖
car🚗
oncoming_automobile🚘
blue_car🚙
pickup_truck🛻
truck🚚
articulated_lorry🚛
tractor🚜
racing_car🏎️
motorcycle🏍️
motor_scooter🛵
manual_wheelchair🦽
motorized_wheelchair🦼
auto_rickshaw🛺
bike🚲
kick_scooter🛴
skateboard🛹
roller_skate🛼
busstop🚏
motorway🛣️
railway_track🛤️
oil_drum🛢️
fuelpump
rotating_light🚨
traffic_light🚥
vertical_traffic_light🚦
stop_sign🛑
construction🚧
anchor
boat
canoe🛶
speedboat🚤
passenger_ship🛳️
ferry⛴️
motor_boat🛥️
ship🚢
airplane✈️
small_airplane🛩️
flight_departure🛫
flight_arrival🛬
parachute🪂
seat💺
helicopter🚁
suspension_railway🚟
mountain_cableway🚠
aerial_tramway🚡
artificial_satellite🛰️
rocket🚀
flying_saucer🛸
bellhop_bell🛎️
luggage🧳
hourglass
hourglass_flowing_sand
watch
alarm_clock
stopwatch⏱️
timer_clock⏲️
mantelpiece_clock🕰️
clock12🕛
clock1230🕧
clock1🕐
clock130🕜
clock2🕑
clock230🕝
clock3🕒
clock330🕞
clock4🕓
clock430🕟
clock5🕔
clock530🕠
clock6🕕
clock630🕡
clock7🕖
clock730🕢
clock8🕗
clock830🕣
clock9🕘
clock930🕤
clock10🕙
clock1030🕥
clock11🕚
clock1130🕦
new_moon🌑
waxing_crescent_moon🌒
first_quarter_moon🌓
moon🌔
full_moon🌕
waning_gibbous_moon🌖
last_quarter_moon🌗
waning_crescent_moon🌘
crescent_moon🌙
new_moon_with_face🌚
first_quarter_moon_with_face🌛
last_quarter_moon_with_face🌜
thermometer🌡️
sunny☀️
full_moon_with_face🌝
sun_with_face🌞
ringed_planet🪐
star
star2🌟
stars🌠
milky_way🌌
cloud☁️
partly_sunny
cloud_with_lightning_and_rain⛈️
sun_behind_small_cloud🌤️
sun_behind_large_cloud🌥️
sun_behind_rain_cloud🌦️
cloud_with_rain🌧️
cloud_with_snow🌨️
cloud_with_lightning🌩️
tornado🌪️
fog🌫️
wind_face🌬️
cyclone🌀
rainbow🌈
closed_umbrella🌂
open_umbrella☂️
umbrella
parasol_on_ground⛱️
zap
snowflake❄️
snowman_with_snow☃️
snowman
comet☄️
fire🔥
droplet💧
ocean🌊
jack_o_lantern🎃
christmas_tree🎄
fireworks🎆
sparkler🎇
firecracker🧨
sparkles
balloon🎈
tada🎉
confetti_ball🎊
tanabata_tree🎋
bamboo🎍
dolls🎎
flags🎏
wind_chime🎐
rice_scene🎑
red_envelope🧧
ribbon🎀
gift🎁
reminder_ribbon🎗️
tickets🎟️
ticket🎫
medal_military🎖️
trophy🏆
medal_sports🏅
1st_place_medal🥇
2nd_place_medal🥈
3rd_place_medal🥉
soccer
baseball
softball🥎
basketball🏀
volleyball🏐
football🏈
rugby_football🏉
tennis🎾
flying_disc🥏
bowling🎳
cricket_game🏏
field_hockey🏑
ice_hockey🏒
lacrosse🥍
ping_pong🏓
badminton🏸
boxing_glove🥊
martial_arts_uniform🥋
goal_net🥅
golf
ice_skate⛸️
fishing_pole_and_fish🎣
diving_mask🤿
running_shirt_with_sash🎽
ski🎿
sled🛷
curling_stone🥌
dart🎯
yo_yo🪀
kite🪁
8ball🎱
crystal_ball🔮
magic_wand🪄
nazar_amulet🧿
video_game🎮
joystick🕹️
slot_machine🎰
game_die🎲
jigsaw🧩
teddy_bear🧸
pinata🪅
nesting_dolls🪆
spades♠️
hearts♥️
diamonds♦️
clubs♣️
chess_pawn♟️
black_joker🃏
mahjong🀄
flower_playing_cards🎴
performing_arts🎭
framed_picture🖼️
art🎨
thread🧵
sewing_needle🪡
yarn🧶
knot🪢
eyeglasses👓
dark_sunglasses🕶️
goggles🥽
lab_coat🥼
safety_vest🦺
necktie👔
shirt👕
jeans👖
scarf🧣
gloves🧤
coat🧥
socks🧦
dress👗
kimono👘
sari🥻
one_piece_swimsuit🩱
swim_brief🩲
shorts🩳
bikini👙
womans_clothes👚
purse👛
handbag👜
pouch👝
shopping🛍️
school_satchel🎒
thong_sandal🩴
mans_shoe👞
athletic_shoe👟
hiking_boot🥾
flat_shoe🥿
high_heel👠
sandal👡
ballet_shoes🩰
boot👢
crown👑
womans_hat👒
tophat🎩
mortar_board🎓
billed_cap🧢
military_helmet🪖
rescue_worker_helmet⛑️
prayer_beads📿
lipstick💄
ring💍
gem💎
mute🔇
speaker🔈
sound🔉
loud_sound🔊
loudspeaker📢
mega📣
postal_horn📯
bell🔔
no_bell🔕
musical_score🎼
musical_note🎵
notes🎶
studio_microphone🎙️
level_slider🎚️
control_knobs🎛️
microphone🎤
headphones🎧
radio📻
saxophone🎷
accordion🪗
guitar🎸
musical_keyboard🎹
trumpet🎺
violin🎻
banjo🪕
drum🥁
long_drum🪘
iphone📱
calling📲
phone☎️
telephone_receiver📞
pager📟
fax📠
battery🔋
electric_plug🔌
computer💻
desktop_computer🖥️
printer🖨️
keyboard⌨️
computer_mouse🖱️
trackball🖲️
minidisc💽
floppy_disk💾
cd💿
dvd📀
abacus🧮
movie_camera🎥
film_strip🎞️
film_projector📽️
clapper🎬
tv📺
camera📷
camera_flash📸
video_camera📹
vhs📼
mag🔍
mag_right🔎
candle🕯️
bulb💡
flashlight🔦
izakaya_lantern🏮
diya_lamp🪔
notebook_with_decorative_cover📔
closed_book📕
book📖
green_book📗
blue_book📘
orange_book📙
books📚
notebook📓
ledger📒
page_with_curl📃
scroll📜
page_facing_up📄
newspaper📰
newspaper_roll🗞️
bookmark_tabs📑
bookmark🔖
label🏷️
moneybag💰
coin🪙
yen💴
dollar💵
euro💶
pound💷
money_with_wings💸
credit_card💳
receipt🧾
chart💹
envelope✉️
email📧
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
TagEmoji
email📧
incoming_envelope📨
envelope_with_arrow📩
outbox_tray📤
inbox_tray📥
package📦
mailbox📫
mailbox_closed📪
mailbox_with_mail📬
mailbox_with_no_mail📭
postbox📮
ballot_box🗳️
pencil2✏️
black_nib✒️
fountain_pen🖋️
pen🖊️
paintbrush🖌️
crayon🖍️
memo📝
briefcase💼
file_folder📁
open_file_folder📂
card_index_dividers🗂️
date📅
calendar📆
spiral_notepad🗒️
spiral_calendar🗓️
card_index📇
chart_with_upwards_trend📈
chart_with_downwards_trend📉
bar_chart📊
clipboard📋
pushpin📌
round_pushpin📍
paperclip📎
paperclips🖇️
straight_ruler📏
triangular_ruler📐
scissors✂️
card_file_box🗃️
file_cabinet🗄️
wastebasket🗑️
lock🔒
unlock🔓
lock_with_ink_pen🔏
closed_lock_with_key🔐
key🔑
old_key🗝️
hammer🔨
axe🪓
pick⛏️
hammer_and_pick⚒️
hammer_and_wrench🛠️
dagger🗡️
crossed_swords⚔️
gun🔫
boomerang🪃
bow_and_arrow🏹
shield🛡️
carpentry_saw🪚
wrench🔧
screwdriver🪛
nut_and_bolt🔩
gear⚙️
clamp🗜️
balance_scale⚖️
probing_cane🦯
link🔗
chains⛓️
hook🪝
toolbox🧰
magnet🧲
ladder🪜
alembic⚗️
test_tube🧪
petri_dish🧫
dna🧬
microscope🔬
telescope🔭
satellite📡
syringe💉
drop_of_blood🩸
pill💊
adhesive_bandage🩹
stethoscope🩺
door🚪
elevator🛗
mirror🪞
window🪟
bed🛏️
couch_and_lamp🛋️
chair🪑
toilet🚽
plunger🪠
shower🚿
bathtub🛁
mouse_trap🪤
razor🪒
lotion_bottle🧴
safety_pin🧷
broom🧹
basket🧺
roll_of_paper🧻
bucket🪣
soap🧼
toothbrush🪥
sponge🧽
fire_extinguisher🧯
shopping_cart🛒
smoking🚬
coffin⚰️
headstone🪦
funeral_urn⚱️
moyai🗿
placard🪧
atm🏧
put_litter_in_its_place🚮
potable_water🚰
wheelchair
mens🚹
womens🚺
restroom🚻
baby_symbol🚼
wc🚾
passport_control🛂
customs🛃
baggage_claim🛄
left_luggage🛅
warning⚠️
children_crossing🚸
no_entry
no_entry_sign🚫
no_bicycles🚳
no_smoking🚭
do_not_litter🚯
non-potable_water🚱
no_pedestrians🚷
no_mobile_phones📵
underage🔞
radioactive☢️
biohazard☣️
arrow_up⬆️
arrow_upper_right↗️
arrow_right➡️
arrow_lower_right↘️
arrow_down⬇️
arrow_lower_left↙️
arrow_left⬅️
arrow_upper_left↖️
arrow_up_down↕️
left_right_arrow↔️
leftwards_arrow_with_hook↩️
arrow_right_hook↪️
arrow_heading_up⤴️
arrow_heading_down⤵️
arrows_clockwise🔃
arrows_counterclockwise🔄
back🔙
end🔚
on🔛
soon🔜
top🔝
place_of_worship🛐
atom_symbol⚛️
om🕉️
star_of_david✡️
wheel_of_dharma☸️
yin_yang☯️
latin_cross✝️
orthodox_cross☦️
star_and_crescent☪️
peace_symbol☮️
menorah🕎
six_pointed_star🔯
aries
taurus
gemini
cancer
leo
virgo
libra
scorpius
sagittarius
capricorn
aquarius
pisces
ophiuchus
twisted_rightwards_arrows🔀
repeat🔁
repeat_one🔂
arrow_forward▶️
fast_forward
next_track_button⏭️
play_or_pause_button⏯️
arrow_backward◀️
rewind
previous_track_button⏮️
arrow_up_small🔼
arrow_double_up
arrow_down_small🔽
arrow_double_down
pause_button⏸️
stop_button⏹️
record_button⏺️
eject_button⏏️
cinema🎦
low_brightness🔅
high_brightness🔆
signal_strength📶
vibration_mode📳
mobile_phone_off📴
female_sign♀️
male_sign♂️
transgender_symbol⚧️
heavy_multiplication_x✖️
heavy_plus_sign
heavy_minus_sign
heavy_division_sign
infinity♾️
bangbang‼️
interrobang⁉️
question
grey_question
grey_exclamation
exclamation
wavy_dash〰️
currency_exchange💱
heavy_dollar_sign💲
medical_symbol⚕️
recycle♻️
fleur_de_lis⚜️
trident🔱
name_badge📛
beginner🔰
o
white_check_mark
ballot_box_with_check☑️
heavy_check_mark✔️
x
negative_squared_cross_mark
curly_loop
loop
part_alternation_mark〽️
eight_spoked_asterisk✳️
eight_pointed_black_star✴️
sparkle❇️
copyright©️
registered®️
tm™️
hash#️⃣
asterisk*️⃣
zero0️⃣
one1️⃣
two2️⃣
three3️⃣
four4️⃣
five5️⃣
six6️⃣
seven7️⃣
eight8️⃣
nine9️⃣
keycap_ten🔟
capital_abcd🔠
abcd🔡
1234🔢
symbols🔣
abc🔤
a🅰️
ab🆎
b🅱️
cl🆑
cool🆒
free🆓
information_sourceℹ️
id🆔
mⓂ️
new🆕
ng🆖
o2🅾️
ok🆗
parking🅿️
sos🆘
up🆙
vs🆚
koko🈁
sa🈂️
u6708🈷️
u6709🈶
u6307🈯
ideograph_advantage🉐
u5272🈹
u7121🈚
u7981🈲
accept🉑
u7533🈸
u5408🈴
u7a7a🈳
congratulations㊗️
secret㊙️
u55b6🈺
u6e80🈵
red_circle🔴
orange_circle🟠
yellow_circle🟡
green_circle🟢
large_blue_circle🔵
purple_circle🟣
brown_circle🟤
black_circle
white_circle
red_square🟥
orange_square🟧
yellow_square🟨
green_square🟩
blue_square🟦
purple_square🟪
brown_square🟫
black_large_square
white_large_square
black_medium_square◼️
white_medium_square◻️
black_medium_small_square
white_medium_small_square
black_small_square▪️
white_small_square▫️
large_orange_diamond🔶
large_blue_diamond🔷
small_orange_diamond🔸
small_blue_diamond🔹
small_red_triangle🔺
small_red_triangle_down🔻
diamond_shape_with_a_dot_inside💠
radio_button🔘
white_square_button🔳
black_square_button🔲
checkered_flag🏁
triangular_flag_on_post🚩
crossed_flags🎌
black_flag🏴
white_flag🏳️
rainbow_flag🏳️‍🌈
transgender_flag🏳️‍⚧️
pirate_flag🏴‍☠️
ascension_island🇦🇨
andorra🇦🇩
united_arab_emirates🇦🇪
afghanistan🇦🇫
antigua_barbuda🇦🇬
anguilla🇦🇮
albania🇦🇱
armenia🇦🇲
angola🇦🇴
antarctica🇦🇶
argentina🇦🇷
american_samoa🇦🇸
austria🇦🇹
australia🇦🇺
aruba🇦🇼
aland_islands🇦🇽
azerbaijan🇦🇿
bosnia_herzegovina🇧🇦
barbados🇧🇧
bangladesh🇧🇩
belgium🇧🇪
burkina_faso🇧🇫
bulgaria🇧🇬
bahrain🇧🇭
burundi🇧🇮
benin🇧🇯
st_barthelemy🇧🇱
bermuda🇧🇲
brunei🇧🇳
bolivia🇧🇴
caribbean_netherlands🇧🇶
brazil🇧🇷
bahamas🇧🇸
bhutan🇧🇹
bouvet_island🇧🇻
botswana🇧🇼
belarus🇧🇾
belize🇧🇿
canada🇨🇦
cocos_islands🇨🇨
congo_kinshasa🇨🇩
central_african_republic🇨🇫
congo_brazzaville🇨🇬
switzerland🇨🇭
cote_divoire🇨🇮
cook_islands🇨🇰
chile🇨🇱
cameroon🇨🇲
cn🇨🇳
colombia🇨🇴
clipperton_island🇨🇵
costa_rica🇨🇷
cuba🇨🇺
cape_verde🇨🇻
curacao🇨🇼
christmas_island🇨🇽
cyprus🇨🇾
czech_republic🇨🇿
de🇩🇪
diego_garcia🇩🇬
djibouti🇩🇯
denmark🇩🇰
dominica🇩🇲
dominican_republic🇩🇴
algeria🇩🇿
ceuta_melilla🇪🇦
ecuador🇪🇨
estonia🇪🇪
egypt🇪🇬
western_sahara🇪🇭
eritrea🇪🇷
es🇪🇸
ethiopia🇪🇹
eu🇪🇺
finland🇫🇮
fiji🇫🇯
falkland_islands🇫🇰
micronesia🇫🇲
faroe_islands🇫🇴
fr🇫🇷
gabon🇬🇦
gb🇬🇧
grenada🇬🇩
georgia🇬🇪
french_guiana🇬🇫
guernsey🇬🇬
ghana🇬🇭
gibraltar🇬🇮
greenland🇬🇱
gambia🇬🇲
guinea🇬🇳
guadeloupe🇬🇵
equatorial_guinea🇬🇶
greece🇬🇷
south_georgia_south_sandwich_islands🇬🇸
guatemala🇬🇹
guam🇬🇺
guinea_bissau🇬🇼
guyana🇬🇾
hong_kong🇭🇰
heard_mcdonald_islands🇭🇲
honduras🇭🇳
croatia🇭🇷
haiti🇭🇹
hungary🇭🇺
canary_islands🇮🇨
indonesia🇮🇩
ireland🇮🇪
israel🇮🇱
isle_of_man🇮🇲
india🇮🇳
british_indian_ocean_territory🇮🇴
iraq🇮🇶
iran🇮🇷
iceland🇮🇸
it🇮🇹
jersey🇯🇪
jamaica🇯🇲
jordan🇯🇴
jp🇯🇵
kenya🇰🇪
kyrgyzstan🇰🇬
cambodia🇰🇭
kiribati🇰🇮
comoros🇰🇲
st_kitts_nevis🇰🇳
north_korea🇰🇵
kr🇰🇷
kuwait🇰🇼
cayman_islands🇰🇾
kazakhstan🇰🇿
laos🇱🇦
lebanon🇱🇧
st_lucia🇱🇨
liechtenstein🇱🇮
sri_lanka🇱🇰
liberia🇱🇷
lesotho🇱🇸
lithuania🇱🇹
luxembourg🇱🇺
latvia🇱🇻
libya🇱🇾
morocco🇲🇦
monaco🇲🇨
moldova🇲🇩
montenegro🇲🇪
st_martin🇲🇫
madagascar🇲🇬
marshall_islands🇲🇭
macedonia🇲🇰
mali🇲🇱
myanmar🇲🇲
mongolia🇲🇳
macau🇲🇴
northern_mariana_islands🇲🇵
martinique🇲🇶
mauritania🇲🇷
montserrat🇲🇸
malta🇲🇹
mauritius🇲🇺
maldives🇲🇻
malawi🇲🇼
mexico🇲🇽
malaysia🇲🇾
mozambique🇲🇿
namibia🇳🇦
new_caledonia🇳🇨
niger🇳🇪
norfolk_island🇳🇫
nigeria🇳🇬
nicaragua🇳🇮
netherlands🇳🇱
norway🇳🇴
nepal🇳🇵
nauru🇳🇷
niue🇳🇺
new_zealand🇳🇿
oman🇴🇲
panama🇵🇦
peru🇵🇪
french_polynesia🇵🇫
papua_new_guinea🇵🇬
philippines🇵🇭
pakistan🇵🇰
poland🇵🇱
st_pierre_miquelon🇵🇲
pitcairn_islands🇵🇳
puerto_rico🇵🇷
palestinian_territories🇵🇸
portugal🇵🇹
palau🇵🇼
paraguay🇵🇾
qatar🇶🇦
reunion🇷🇪
romania🇷🇴
serbia🇷🇸
ru🇷🇺
rwanda🇷🇼
saudi_arabia🇸🇦
solomon_islands🇸🇧
seychelles🇸🇨
sudan🇸🇩
sweden🇸🇪
singapore🇸🇬
st_helena🇸🇭
slovenia🇸🇮
svalbard_jan_mayen🇸🇯
slovakia🇸🇰
sierra_leone🇸🇱
san_marino🇸🇲
senegal🇸🇳
somalia🇸🇴
suriname🇸🇷
south_sudan🇸🇸
sao_tome_principe🇸🇹
el_salvador🇸🇻
sint_maarten🇸🇽
syria🇸🇾
swaziland🇸🇿
tristan_da_cunha🇹🇦
turks_caicos_islands🇹🇨
chad🇹🇩
french_southern_territories🇹🇫
togo🇹🇬
thailand🇹🇭
tajikistan🇹🇯
tokelau🇹🇰
timor_leste🇹🇱
turkmenistan🇹🇲
tunisia🇹🇳
tonga🇹🇴
tr🇹🇷
trinidad_tobago🇹🇹
tuvalu🇹🇻
taiwan🇹🇼
tanzania🇹🇿
ukraine🇺🇦
uganda🇺🇬
us_outlying_islands🇺🇲
united_nations🇺🇳
us🇺🇸
uruguay🇺🇾
uzbekistan🇺🇿
vatican_city🇻🇦
st_vincent_grenadines🇻🇨
venezuela🇻🇪
british_virgin_islands🇻🇬
us_virgin_islands🇻🇮
vietnam🇻🇳
vanuatu🇻🇺
wallis_futuna🇼🇫
samoa🇼🇸
kosovo🇽🇰
yemen🇾🇪
mayotte🇾🇹
south_africa🇿🇦
zambia🇿🇲
zimbabwe🇿🇼
england🏴󠁧󠁢󠁥󠁮󠁧󠁿
scotland🏴󠁧󠁢󠁳󠁣󠁴󠁿
wales🏴󠁧󠁢󠁷󠁬󠁳󠁿
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagEmoji
email📧
incoming_envelope📨
envelope_with_arrow📩
outbox_tray📤
inbox_tray📥
package📦
mailbox📫
mailbox_closed📪
mailbox_with_mail📬
mailbox_with_no_mail📭
postbox📮
ballot_box🗳️
pencil2✏️
black_nib✒️
fountain_pen🖋️
pen🖊️
paintbrush🖌️
crayon🖍️
memo📝
briefcase💼
file_folder📁
open_file_folder📂
card_index_dividers🗂️
date📅
calendar📆
spiral_notepad🗒️
spiral_calendar🗓️
card_index📇
chart_with_upwards_trend📈
chart_with_downwards_trend📉
bar_chart📊
clipboard📋
pushpin📌
round_pushpin📍
paperclip📎
paperclips🖇️
straight_ruler📏
triangular_ruler📐
scissors✂️
card_file_box🗃️
file_cabinet🗄️
wastebasket🗑️
lock🔒
unlock🔓
lock_with_ink_pen🔏
closed_lock_with_key🔐
key🔑
old_key🗝️
hammer🔨
axe🪓
pick⛏️
hammer_and_pick⚒️
hammer_and_wrench🛠️
dagger🗡️
crossed_swords⚔️
gun🔫
boomerang🪃
bow_and_arrow🏹
shield🛡️
carpentry_saw🪚
wrench🔧
screwdriver🪛
nut_and_bolt🔩
gear⚙️
clamp🗜️
balance_scale⚖️
probing_cane🦯
link🔗
chains⛓️
hook🪝
toolbox🧰
magnet🧲
ladder🪜
alembic⚗️
test_tube🧪
petri_dish🧫
dna🧬
microscope🔬
telescope🔭
satellite📡
syringe💉
drop_of_blood🩸
pill💊
adhesive_bandage🩹
stethoscope🩺
door🚪
elevator🛗
mirror🪞
window🪟
bed🛏️
couch_and_lamp🛋️
chair🪑
toilet🚽
plunger🪠
shower🚿
bathtub🛁
mouse_trap🪤
razor🪒
lotion_bottle🧴
safety_pin🧷
broom🧹
basket🧺
roll_of_paper🧻
bucket🪣
soap🧼
toothbrush🪥
sponge🧽
fire_extinguisher🧯
shopping_cart🛒
smoking🚬
coffin⚰️
headstone🪦
funeral_urn⚱️
moyai🗿
placard🪧
atm🏧
put_litter_in_its_place🚮
potable_water🚰
wheelchair
mens🚹
womens🚺
restroom🚻
baby_symbol🚼
wc🚾
passport_control🛂
customs🛃
baggage_claim🛄
left_luggage🛅
warning⚠️
children_crossing🚸
no_entry
no_entry_sign🚫
no_bicycles🚳
no_smoking🚭
do_not_litter🚯
non-potable_water🚱
no_pedestrians🚷
no_mobile_phones📵
underage🔞
radioactive☢️
biohazard☣️
arrow_up⬆️
arrow_upper_right↗️
arrow_right➡️
arrow_lower_right↘️
arrow_down⬇️
arrow_lower_left↙️
arrow_left⬅️
arrow_upper_left↖️
arrow_up_down↕️
left_right_arrow↔️
leftwards_arrow_with_hook↩️
arrow_right_hook↪️
arrow_heading_up⤴️
arrow_heading_down⤵️
arrows_clockwise🔃
arrows_counterclockwise🔄
back🔙
end🔚
on🔛
soon🔜
top🔝
place_of_worship🛐
atom_symbol⚛️
om🕉️
star_of_david✡️
wheel_of_dharma☸️
yin_yang☯️
latin_cross✝️
orthodox_cross☦️
star_and_crescent☪️
peace_symbol☮️
menorah🕎
six_pointed_star🔯
aries
taurus
gemini
cancer
leo
virgo
libra
scorpius
sagittarius
capricorn
aquarius
pisces
ophiuchus
twisted_rightwards_arrows🔀
repeat🔁
repeat_one🔂
arrow_forward▶️
fast_forward
next_track_button⏭️
play_or_pause_button⏯️
arrow_backward◀️
rewind
previous_track_button⏮️
arrow_up_small🔼
arrow_double_up
arrow_down_small🔽
arrow_double_down
pause_button⏸️
stop_button⏹️
record_button⏺️
eject_button⏏️
cinema🎦
low_brightness🔅
high_brightness🔆
signal_strength📶
vibration_mode📳
mobile_phone_off📴
female_sign♀️
male_sign♂️
transgender_symbol⚧️
heavy_multiplication_x✖️
heavy_plus_sign
heavy_minus_sign
heavy_division_sign
infinity♾️
bangbang‼️
interrobang⁉️
question
grey_question
grey_exclamation
exclamation
wavy_dash〰️
currency_exchange💱
heavy_dollar_sign💲
medical_symbol⚕️
recycle♻️
fleur_de_lis⚜️
trident🔱
name_badge📛
beginner🔰
o
white_check_mark
ballot_box_with_check☑️
heavy_check_mark✔️
x
negative_squared_cross_mark
curly_loop
loop
part_alternation_mark〽️
eight_spoked_asterisk✳️
eight_pointed_black_star✴️
sparkle❇️
copyright©️
registered®️
tm™️
hash#️⃣
asterisk*️⃣
zero0️⃣
one1️⃣
two2️⃣
three3️⃣
four4️⃣
five5️⃣
six6️⃣
seven7️⃣
eight8️⃣
nine9️⃣
keycap_ten🔟
capital_abcd🔠
abcd🔡
1234🔢
symbols🔣
abc🔤
a🅰️
ab🆎
b🅱️
cl🆑
cool🆒
free🆓
information_sourceℹ️
id🆔
mⓂ️
new🆕
ng🆖
o2🅾️
ok🆗
parking🅿️
sos🆘
up🆙
vs🆚
koko🈁
sa🈂️
u6708🈷️
u6709🈶
u6307🈯
ideograph_advantage🉐
u5272🈹
u7121🈚
u7981🈲
accept🉑
u7533🈸
u5408🈴
u7a7a🈳
congratulations㊗️
secret㊙️
u55b6🈺
u6e80🈵
red_circle🔴
orange_circle🟠
yellow_circle🟡
green_circle🟢
large_blue_circle🔵
purple_circle🟣
brown_circle🟤
black_circle
white_circle
red_square🟥
orange_square🟧
yellow_square🟨
green_square🟩
blue_square🟦
purple_square🟪
brown_square🟫
black_large_square
white_large_square
black_medium_square◼️
white_medium_square◻️
black_medium_small_square
white_medium_small_square
black_small_square▪️
white_small_square▫️
large_orange_diamond🔶
large_blue_diamond🔷
small_orange_diamond🔸
small_blue_diamond🔹
small_red_triangle🔺
small_red_triangle_down🔻
diamond_shape_with_a_dot_inside💠
radio_button🔘
white_square_button🔳
black_square_button🔲
checkered_flag🏁
triangular_flag_on_post🚩
crossed_flags🎌
black_flag🏴
white_flag🏳️
rainbow_flag🏳️‍🌈
transgender_flag🏳️‍⚧️
pirate_flag🏴‍☠️
ascension_island🇦🇨
andorra🇦🇩
united_arab_emirates🇦🇪
afghanistan🇦🇫
antigua_barbuda🇦🇬
anguilla🇦🇮
albania🇦🇱
armenia🇦🇲
angola🇦🇴
antarctica🇦🇶
argentina🇦🇷
american_samoa🇦🇸
austria🇦🇹
australia🇦🇺
aruba🇦🇼
aland_islands🇦🇽
azerbaijan🇦🇿
bosnia_herzegovina🇧🇦
barbados🇧🇧
bangladesh🇧🇩
belgium🇧🇪
burkina_faso🇧🇫
bulgaria🇧🇬
bahrain🇧🇭
burundi🇧🇮
benin🇧🇯
st_barthelemy🇧🇱
bermuda🇧🇲
brunei🇧🇳
bolivia🇧🇴
caribbean_netherlands🇧🇶
brazil🇧🇷
bahamas🇧🇸
bhutan🇧🇹
bouvet_island🇧🇻
botswana🇧🇼
belarus🇧🇾
belize🇧🇿
canada🇨🇦
cocos_islands🇨🇨
congo_kinshasa🇨🇩
central_african_republic🇨🇫
congo_brazzaville🇨🇬
switzerland🇨🇭
cote_divoire🇨🇮
cook_islands🇨🇰
chile🇨🇱
cameroon🇨🇲
cn🇨🇳
colombia🇨🇴
clipperton_island🇨🇵
costa_rica🇨🇷
cuba🇨🇺
cape_verde🇨🇻
curacao🇨🇼
christmas_island🇨🇽
cyprus🇨🇾
czech_republic🇨🇿
de🇩🇪
diego_garcia🇩🇬
djibouti🇩🇯
denmark🇩🇰
dominica🇩🇲
dominican_republic🇩🇴
algeria🇩🇿
ceuta_melilla🇪🇦
ecuador🇪🇨
estonia🇪🇪
egypt🇪🇬
western_sahara🇪🇭
eritrea🇪🇷
es🇪🇸
ethiopia🇪🇹
eu🇪🇺
finland🇫🇮
fiji🇫🇯
falkland_islands🇫🇰
micronesia🇫🇲
faroe_islands🇫🇴
fr🇫🇷
gabon🇬🇦
gb🇬🇧
grenada🇬🇩
georgia🇬🇪
french_guiana🇬🇫
guernsey🇬🇬
ghana🇬🇭
gibraltar🇬🇮
greenland🇬🇱
gambia🇬🇲
guinea🇬🇳
guadeloupe🇬🇵
equatorial_guinea🇬🇶
greece🇬🇷
south_georgia_south_sandwich_islands🇬🇸
guatemala🇬🇹
guam🇬🇺
guinea_bissau🇬🇼
guyana🇬🇾
hong_kong🇭🇰
heard_mcdonald_islands🇭🇲
honduras🇭🇳
croatia🇭🇷
haiti🇭🇹
hungary🇭🇺
canary_islands🇮🇨
indonesia🇮🇩
ireland🇮🇪
israel🇮🇱
isle_of_man🇮🇲
india🇮🇳
british_indian_ocean_territory🇮🇴
iraq🇮🇶
iran🇮🇷
iceland🇮🇸
it🇮🇹
jersey🇯🇪
jamaica🇯🇲
jordan🇯🇴
jp🇯🇵
kenya🇰🇪
kyrgyzstan🇰🇬
cambodia🇰🇭
kiribati🇰🇮
comoros🇰🇲
st_kitts_nevis🇰🇳
north_korea🇰🇵
kr🇰🇷
kuwait🇰🇼
cayman_islands🇰🇾
kazakhstan🇰🇿
laos🇱🇦
lebanon🇱🇧
st_lucia🇱🇨
liechtenstein🇱🇮
sri_lanka🇱🇰
liberia🇱🇷
lesotho🇱🇸
lithuania🇱🇹
luxembourg🇱🇺
latvia🇱🇻
libya🇱🇾
morocco🇲🇦
monaco🇲🇨
moldova🇲🇩
montenegro🇲🇪
st_martin🇲🇫
madagascar🇲🇬
marshall_islands🇲🇭
macedonia🇲🇰
mali🇲🇱
myanmar🇲🇲
mongolia🇲🇳
macau🇲🇴
northern_mariana_islands🇲🇵
martinique🇲🇶
mauritania🇲🇷
montserrat🇲🇸
malta🇲🇹
mauritius🇲🇺
maldives🇲🇻
malawi🇲🇼
mexico🇲🇽
malaysia🇲🇾
mozambique🇲🇿
namibia🇳🇦
new_caledonia🇳🇨
niger🇳🇪
norfolk_island🇳🇫
nigeria🇳🇬
nicaragua🇳🇮
netherlands🇳🇱
norway🇳🇴
nepal🇳🇵
nauru🇳🇷
niue🇳🇺
new_zealand🇳🇿
oman🇴🇲
panama🇵🇦
peru🇵🇪
french_polynesia🇵🇫
papua_new_guinea🇵🇬
philippines🇵🇭
pakistan🇵🇰
poland🇵🇱
st_pierre_miquelon🇵🇲
pitcairn_islands🇵🇳
puerto_rico🇵🇷
palestinian_territories🇵🇸
portugal🇵🇹
palau🇵🇼
paraguay🇵🇾
qatar🇶🇦
reunion🇷🇪
romania🇷🇴
serbia🇷🇸
ru🇷🇺
rwanda🇷🇼
saudi_arabia🇸🇦
solomon_islands🇸🇧
seychelles🇸🇨
sudan🇸🇩
sweden🇸🇪
singapore🇸🇬
st_helena🇸🇭
slovenia🇸🇮
svalbard_jan_mayen🇸🇯
slovakia🇸🇰
sierra_leone🇸🇱
san_marino🇸🇲
senegal🇸🇳
somalia🇸🇴
suriname🇸🇷
south_sudan🇸🇸
sao_tome_principe🇸🇹
el_salvador🇸🇻
sint_maarten🇸🇽
syria🇸🇾
swaziland🇸🇿
tristan_da_cunha🇹🇦
turks_caicos_islands🇹🇨
chad🇹🇩
french_southern_territories🇹🇫
togo🇹🇬
thailand🇹🇭
tajikistan🇹🇯
tokelau🇹🇰
timor_leste🇹🇱
turkmenistan🇹🇲
tunisia🇹🇳
tonga🇹🇴
tr🇹🇷
trinidad_tobago🇹🇹
tuvalu🇹🇻
taiwan🇹🇼
tanzania🇹🇿
ukraine🇺🇦
uganda🇺🇬
us_outlying_islands🇺🇲
united_nations🇺🇳
us🇺🇸
uruguay🇺🇾
uzbekistan🇺🇿
vatican_city🇻🇦
st_vincent_grenadines🇻🇨
venezuela🇻🇪
british_virgin_islands🇻🇬
us_virgin_islands🇻🇮
vietnam🇻🇳
vanuatu🇻🇺
wallis_futuna🇼🇫
samoa🇼🇸
kosovo🇽🇰
yemen🇾🇪
mayotte🇾🇹
south_africa🇿🇦
zambia🇿🇲
zimbabwe🇿🇼
england🏴󠁧󠁢󠁥󠁮󠁧󠁿
scotland🏴󠁧󠁢󠁳󠁣󠁴󠁿
wales🏴󠁧󠁢󠁷󠁬󠁳󠁿
diff --git a/docs/examples.md b/docs/examples.md index 8164e2b..0c892e6 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -16,7 +16,7 @@ I started adding notifications pretty much all of my scripts. Typically, I just directly to the command I'm running. The following example will either send Laptop backup succeeded or ⚠️ Laptop backup failed directly to my phone: -``` bash +``` rsync -a root@laptop /backups/laptop \ && zfs snapshot ... \ && curl -H prio:low -d "Laptop backup succeeded" ntfy.sh/backups \ @@ -26,7 +26,7 @@ rsync -a root@laptop /backups/laptop \ Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with GitHub have been hopeless. In case it ever becomes available, I want to know immediately. -``` +``` cron # Check github/ntfy user */6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi ``` @@ -136,33 +136,27 @@ You can send a message during a workflow run with curl. Here is an example sendi ``` ## Watchtower (shoutrrr) -You can use [shoutrrr](https://containrrr.dev/shoutrrr/latest/services/ntfy/) to send +You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send [Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic. Example docker-compose.yml: - ``` yaml services: watchtower: image: containrrr/watchtower environment: - WATCHTOWER_NOTIFICATIONS=shoutrrr - - WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates + - WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates ``` Or, if you only want to send notifications using shoutrrr: ``` -shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" +shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" ``` ## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd - - - -Radarr, Prowlarr, and Sonarr v4 support ntfy natively under Settings > Connect. - -Sonarr v3, Readarr, and SABnzbd support custom scripts for downloads, warnings, grabs, etc. -Some simple bash scripts to achieve this are kindly provided in [nickexyz's ntfy-shellscripts repository](https://github.com/nickexyz/ntfy-shellscripts). +It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc. +Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts). ## Node-RED You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples: @@ -578,27 +572,4 @@ Example `template.html`: Add notification on Rundeck (attachment type must be: `Attached as file to email`): ![Rundeck](static/img/rundeck.png) -## Traccar -This will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](https://github.com/traccar/traccar)) instances, as you need to be able to set `sms.http.*` keys, which is not possible through the UI attributes -The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails. - -**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json)) -```xml - https://ntfy.sh - - { - "topic": "{phone}", - "message": "{message}" - } - -``` -If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, you'll also have to provide an authorization header, for example in form of a privileged token -```xml - Bearer tk_JhbsnoMrgy2FcfHeofv97Pi5uXaZZ -``` -or by simply providing traccar with a valid username/password combination. -```xml - phil - mypass -``` diff --git a/docs/faq.md b/docs/faq.md index d7977a5..b6bf517 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -43,9 +43,9 @@ of the app and [self-host your own ntfy server](install.md). ## How much battery does the Android app use? If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature, the Android/iOS app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server, -or you use *instant delivery* (Android only), or install from F-droid ([which does not support FCM](https://f-droid.org/docs/Inclusion_Policy/)), -the app has to maintain a constant connection to the server, which consumes about 0-1% of battery in 17h of use (on my phone). -There has been a ton of testing and improvement around this. I think it's pretty decent now. +or you use *instant delivery* (Android only), the app has to maintain a constant connection to the server, which consumes +about 0-1% of battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty +decent now. ## Paid plans? I thought it was open source? All of ntfy will remain open source, with a free software license (Apache 2.0 and GPLv2). If you'd like to self-host, you diff --git a/docs/hooks.py b/docs/hooks.py deleted file mode 100644 index cdb31a5..0000000 --- a/docs/hooks.py +++ /dev/null @@ -1,6 +0,0 @@ -import os -import shutil - -def copy_fonts(config, **kwargs): - site_dir = config['site_dir'] - shutil.copytree('docs/static/fonts', os.path.join(site_dir, 'get')) diff --git a/docs/install.md b/docs/install.md index 1d28495..2c44031 100644 --- a/docs/install.md +++ b/docs/install.md @@ -20,46 +20,43 @@ To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` wh To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI](subscribe/cli.md) for details). -If you like video tutorials, check out :simple-youtube: [Kris Occhipinti's ntfy install guide](https://www.youtube.com/watch?v=bZzqrX05mNU). -It's short and to the point. _I am not affiliated with Kris, I just liked the video._ - ## Linux binaries Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and deb/rpm packages. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_x86_64.tar.gz - tar zxvf ntfy_2.5.0_linux_x86_64.tar.gz - sudo cp -a ntfy_2.5.0_linux_x86_64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_x86_64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_x86_64.tar.gz + tar zxvf ntfy_2.0.0_linux_x86_64.tar.gz + sudo cp -a ntfy_2.0.0_linux_x86_64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_x86_64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.tar.gz - tar zxvf ntfy_2.5.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.5.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.tar.gz + tar zxvf ntfy_2.0.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.0.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.tar.gz - tar zxvf ntfy_2.5.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.5.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.tar.gz + tar zxvf ntfy_2.0.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.0.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.tar.gz - tar zxvf ntfy_2.5.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.5.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.tar.gz + tar zxvf ntfy_2.0.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.0.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -109,7 +106,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -117,7 +114,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -125,7 +122,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -133,7 +130,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -143,28 +140,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -192,36 +189,30 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos. ## macOS The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. -To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_macOS_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_macOS_all.tar.gz), extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). ```bash -curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_macOS_all.tar.gz > ntfy_2.5.0_macOS_all.tar.gz -tar zxvf ntfy_2.5.0_macOS_all.tar.gz -sudo cp -a ntfy_2.5.0_macOS_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_macOS_all.tar.gz > ntfy_2.0.0_macOS_all.tar.gz +tar zxvf ntfy_2.0.0_macOS_all.tar.gz +sudo cp -a ntfy_2.0.0_macOS_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.5.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.0.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` !!! info - Only the ntfy CLI is supported on macOS. ntfy server is currently not supported, but you can build and run it for - development as well. Check out the [build instructions](develop.md) for details. - -## Homebrew -To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS), -simply run: -``` -brew install ntfy -``` - + There is a [GitHub issue](https://github.com/binwiederhier/ntfy/issues/286) about making ntfy installable via + [Homebrew](https://brew.sh/). I'll eventually get to that, but I'd also love if somebody else stepped up to do it. + Also, you can build and run the ntfy server on macOS as well, though I don't officially support that. + Check out the [build instructions](develop.md) for details. ## Windows The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. -To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_windows_x86_64.zip), +To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_windows_x86_64.zip), extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). @@ -275,7 +266,7 @@ docker run \ serve ``` -Using docker-compose with non-root user and healthchecks enabled: +Using docker-compose with non-root user: ```yaml version: "2.1" @@ -293,12 +284,6 @@ services: - /etc/ntfy:/etc/ntfy ports: - 80:80 - healthcheck: # optional: remember to adapt the host:port to your environment - test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"] - interval: 60s - timeout: 10s - retries: 3 - start_period: 40s restart: unless-stopped ``` diff --git a/docs/integrations.md b/docs/integrations.md index d1a4d42..1e45ab0 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -4,6 +4,22 @@ There are quite a few projects that work with ntfy, integrate ntfy, or have been I've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community. +## Public ntfy servers + +Here's a list of public ntfy servers. As of right now, there is only one official server. The others are provided by the +ntfy community. Thanks to everyone running a public server. **You guys rock!** + +| URL | Country | +|---------------------------------------------------|--------------------| +| [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States | +| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 France | +| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland | +| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany | +| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany | + +Please be aware that **server operators can log your messages**. The project also cannot guarantee the reliability +and uptime of third party servers, so use of each server is **at your own discretion**. + ## Official integrations - [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs @@ -17,20 +33,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool - [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media - [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends. -- [Netdata](https://learn.netdata.cloud/docs/alerts-and-notifications/notifications/agent-alert-notifications/ntfy) ⭐ - Real-time performance monitoring -- [Deployer](https://github.com/deployphp/deployer) ⭐ - PHP deployment tool - [Scrt.link](https://scrt.link/) - Share a secret - [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python -- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier -- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server - -## Integration via HTTP/SMTP/etc. - -- [Watchtower](https://containrrr.dev/watchtower/) ⭐ - Automating Docker container base image updates (see [integration example](examples.md#watchtower-shoutrrr)) -- [Jellyfin](https://jellyfin.org/) ⭐ - The Free Software Media System (see [integration example](examples.md#)) -- [Overseer](https://docs.overseerr.dev/using-overseerr/notifications/webhooks) ⭐ - a request management and media discovery tool for Plex (see [integration example](examples.md#jellyseerroverseerr-webhook)) -- [Tautulli](https://github.com/Tautulli/Tautulli) ⭐ - Monitoring and tracking tool for Plex (integration [via webhook](https://github.com/Tautulli/Tautulli/wiki/Notification-Agents-Guide#webhook)) -- [Mailrise](https://github.com/YoRyan/mailrise) - An SMTP gateway (integration via [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy)) ## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations @@ -54,7 +58,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy](https://github.com/jonocarroll/ntfy) - Wraps the ntfy API with pipe-friendly tooling (R) - [ntfy-for-delphi](https://github.com/hazzelnuts/ntfy-for-delphi) - A friendly library to push instant notifications ntfy (Delphi) - [ntfy](https://github.com/ffflorian/ntfy) - Send notifications over ntfy (JS) -- [ntfy_dart](https://github.com/jr1221/ntfy_dart) - Dart wrapper around the ntfy API (Dart) ## CLIs + GUIs @@ -70,7 +73,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust) - [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go) -- [Grafana-to-ntfy](https://gitlab.com/Saibe1111/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Node Js) - [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh) - [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell) - [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell) @@ -80,7 +82,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy-server-status](https://github.com/filip2cz/ntfy-server-status) - Checking if server is online and reporting through ntfy (C) - [borg-based backup](https://github.com/davidhi7/backup) - Simple borg-based backup script with notifications based on ntfy.sh or Discord webhooks (Python/Shell) - [ntfy.sh *arr script](https://github.com/agent-squirrel/nfty-arr-script) - Quick and hacky script to get sonarr/radarr to notify the ntfy.sh service (Shell) -- [website-watcher](https://github.com/muety/website-watcher) - A small tool to watch websites for changes (with XPath support) (Python) - [siteeagle](https://github.com/tpanum/siteeagle) - A small Python script to monitor websites and notify changes (Python) - [send_to_phone](https://github.com/whipped-cream/send_to_phone) - Scripts to upload a file to Transfer.sh and ping ntfy with the download link (Python) - [ntfy Discord bot](https://github.com/R0dn3yS/ntfy-bot) - WIP ntfy discord bot (TypeScript) @@ -104,38 +105,15 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy - [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust) - [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost -- [woodpecker-ntfy](https://codeberg.org/l-x/woodpecker-ntfy)- Woodpecker CI plugin for sending ntfy notfication from a pipeline (Go) - [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline (Shell) - [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java) - [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python) - [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python) - [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums - [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows -- [nlog-ntfy](https://github.com/MichelMichels/nlog-ntfy) - Send NLog messages over ntfy (C# / .NET / NLog) -- [helm-charts](https://github.com/sarab97/helm-charts) - Helm charts of some of the selfhosted services, incl. ntfy -- [ntfy_ansible_role](https://github.com/stevenengland/ntfy_ansible_role) (on [Ansible Galaxy](https://galaxy.ansible.com/stevenengland/ntfy)) - Ansible role to install ntfy -- [easy2ntfy](https://github.com/chromoxdor/easy2ntfy) - Gateway for ESPeasy to receive commands through ntfy and using easyfetch (HTML/JS) -- [ntfy_lite](https://github.com/MPI-IS/ntfy_lite) - Minimalist python API for pushing ntfy notifications (Python) -- [notify](https://github.com/guanguans/notify) - 推送通知 (PHP) -- [zpool-events](https://github.com/maglar0/zpool-events) - Notify on ZFS pool events (Python) -- [ntfyd](https://github.com/joachimschmidt557/ntfyd) - ntfy desktop daemon (Zig) -- [ntfy-browser](https://github.com/johman10/ntfy-browser) - browser extension to receive notifications without having the page open (TypeScript) -- [ntfy-electron](https://github.com/xdpirate/ntfy-electron) - Electron wrapper for the ntfy web app (JS) ## Blog + forum posts -- [ntfy: des notifications instantanées](https://blogmotion.fr/diy/ntfy-notification-push-domotique-20708) - blogmotion.fr - 5/2023 -- [桌面通知:ntfy](https://www.cnblogs.com/xueweihan/archive/2023/05/04/17370060.html) - cnblogs.com - 5/2023 -- [ntfy.sh - Open source push notifications via PUT/POST](https://lobste.rs/s/5drapz/ntfy_sh_open_source_push_notifications) - lobste.rs - 5/2023 -- [Install ntfy Inside Docker Container in Linux](https://lindevs.com/install-ntfy-inside-docker-container-in-linux) - lindevs.com - 4/2023 -- [ntfy.sh](https://neo-sahara.com/wp/2023/03/25/ntfy-sh/) - neo-sahara.com - 3/2023 -- [Using Ntfy to send and receive push notifications - Samuel Rosa de Oliveria - Delphicon 2023](https://www.youtube.com/watch?v=feu0skpI9QI) - youtube.com - 3/2023 -- [ntfy: własny darmowy system powiadomień](https://sprawdzone.it/ntfy-wlasny-darmowy-system-powiadomien/) - sprawdzone.it - 3/2023 -- [Deploying ntfy on railway](https://www.youtube.com/watch?v=auJICXtxoNA) - youtube.com - 3/2023 -- [Start-Job,Variables, and ntfy.sh](https://klingele.dev/2023/03/01/start-jobvariables-and-ntfy-sh/) - klingele.dev - 3/2023 -- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023 -- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023 -- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023 - [Video: Simple Push Notifications ntfy](https://www.youtube.com/watch?v=u9EcWrsjE20) ⭐ - youtube.com - 2/2023 - [Use ntfy.sh with Home Assistant](https://diecknet.de/en/2023/02/12/ntfy-sh-with-homeassistant/) - diecknet.de - 2/2023 - [On installe Ntfy sur Synology Docker](https://www.maison-et-domotique.com/140356-serveur-notification-jeedom-ntfy-synology-docker/) - maison-et-domotique.co - 1/2023 @@ -144,12 +122,10 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022 - [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022 - [Ntfy Self-Hosted Push Notifications](https://lachlanlife.net/posts/2022-12-ntfy/) - lachlanlife.net - 12/2022 -- [NTFY - système de notification hyper simple et complet](https://www.youtube.com/watch?v=UieZYWVVgA4) - youtube.com - 12/2022 - [ntfy.sh](https://paramdeo.com/til/ntfy-sh) - paramdeo.com - 11/2022 - [Using ntfy to warn me when my computer is discharging](https://ulysseszh.github.io/programming/2022/11/28/ntfy-warn-discharge.html) - ulysseszh.github.io - 11/2022 - [ntfy - Push Notification Service](https://dizzytech.de/posts/ntfy/) - dizzytech.de - 11/2022 - [Console #132](https://console.substack.com/p/console-132) ⭐ - console.substack.com - 11/2022 -- [How to make my phone buzz*](https://evbogue.com/howtomakemyphonebuzz) - evbogue.com - 11/2022 - [MeshCentral - Ntfy Push Notifications ](https://www.youtube.com/watch?v=wyE4rtUd4Bg) - youtube.com - 11/2022 - [Changelog | Tracking layoffs, tech worker demand still high, ntfy, ...](https://changelog.com/news/tracking-layoffs-tech-worker-demand-still-high-ntfy-devenv-markdoc-mike-bifulco-Y1jW) ⭐ - changelog.com - 11/2022 - [Pointer | Issue #367](https://www.pointer.io/archives/a9495a2a6f/) - pointer.io - 11/2022 @@ -187,22 +163,3 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy otro sistema de notificaciones pub-sub simple basado en HTTP](https://ugeek.github.io/blog/post/2021-11-05-ntfy-sh-otro-sistema-de-notificaciones-pub-sub-simple-basado-en-http.html) - ugeek.github.io - 11/2021 - [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - news.ycombinator.com - 12/2021 - [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021 - - -## Alternative ntfy servers - -Here's a list of public ntfy servers. As of right now, there is only one official server. The others are provided by the -ntfy community. Thanks to everyone running a public server. **You guys rock!** - -| URL | Country | -|---------------------------------------------------|--------------------| -| [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States | -| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 France | -| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland | -| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany | -| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany | -| [ntfy.mzte.de](https://ntfy.mzte.de/) | 🇩🇪 Germany | -| [ntfy.hostux.net](https://ntfy.hostux.net/) | 🇫🇷 France | - -Please be aware that **server operators can log your messages**. The project also cannot guarantee the reliability -and uptime of third party servers, so use of each server is **at your own discretion**. diff --git a/docs/known-issues.md b/docs/known-issues.md index f052842..defb4a6 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -8,7 +8,7 @@ For some (many?) users, the iOS app is not refreshing the view when new notifica swipe down, you do not see the newly arrived messages, even though the popup appeared before. This is caused by some weirdness between the Notification Service Extension (NSE), SwiftUI and Core Data. I am entirely -clueless on how to fix it, sadly, as it is ephemeral and not clear to me what is causing it. +clueless on how to fix it, sadly, as it is ephemeral and now clear to me what is causing it. Please send experienced iOS developers my way to help me figure this out. diff --git a/docs/publish.md b/docs/publish.md index 11e33e6..d0071fd 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -38,12 +38,7 @@ Here's an example showing how to publish a simple message using a POST request: === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/mytopic" - Body = "Backup successful" - } - Invoke-RestMethod @Request + Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/mytopic -Body "Backup successful" -UseBasicParsing ``` === "Python" @@ -129,17 +124,12 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/phil_alerts" - Headers = @{ - Title = "Unauthorized access detected" - Priority = "urgent" - Tags = "warning,skull" - } - Body = "Remote access to phils-laptop detected. Act right away." - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/phil_alerts" + $headers = @{ Title="Unauthorized access detected" + Priority="urgent" + Tags="warning,skull" } + $body = "Remote access to phils-laptop detected. Act right away." + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -252,21 +242,18 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](# === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/mydoorbell" - Headers = @{ - Click = "https://home.nest.com" - Attach = "https://nest.com/view/yAxksd.jpg" - Actions = "http, Open door, https://api.nest.com/open/yAxkasd, clear=true" - Email = "phil@example.com" - } - Body = "There's someone at the door. 🐶`n - `n - Please check if it's a good boy or a hooman.`n - Doggies have been known to ring the doorbell.`n" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/mydoorbell" + $headers = @{ Click="https://home.nest.com/" + Attach="https://nest.com/view/yAxkasd.jpg" + Actions="http, Open door, https://api.nest.com/open/yAxkasd, clear=true" + Email="phil@example.com" } + $body = @' + There's someone at the door. 🐶 + + Please check if it's a good boy or a hooman. + Doggies have been known to ring the doorbell. + '@ + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -355,15 +342,10 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`). === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/controversial" - Headers = @{ - Title = "Dogs are better than cats" - } - Body = "Oh my ..." - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/controversial" + $headers = @{ Title="Dogs are better than cats" } + $body = "Oh my ..." + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -391,12 +373,6 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
Detail view of notification with title
-!!! info - ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). - If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including the title) - as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), - or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). - ## Message priority _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -456,14 +432,10 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P === "PowerShell" ``` powershell - $Request = @{ - URI = "https://ntfy.sh/phil_alerts" - Headers = @{ - Priority = "5" - } - Body = "An urgent message" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/phil_alerts" + $headers = @{ Priority="5" } + $body = "An urgent message" + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -581,15 +553,10 @@ them with a comma, e.g. `tag1,tag2,tag3`. === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/backups" - Headers = @{ - Tags = "warning,mailsrv13,daily-backup" - } - Body = "Backup of mailsrv13 failed" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/backups" + $headers = @{ Tags="warning,mailsrv13,daily-backup" } + $body = "Backup of mailsrv13 failed" + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -617,12 +584,6 @@ them with a comma, e.g. `tag1,tag2,tag3`.
Detail view of notifications with tags
-!!! info - ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). - If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the tags header or individual tags - as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), - or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). - ## Scheduled delivery _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -684,15 +645,10 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/hello" - Headers = @{ - At = "tomorrow, 10am" - } - Body = "Good morning" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/hello" + $headers = @{ At="tomorrow, 10am" } + $body = "Good morning" + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -773,7 +729,7 @@ For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhoo === "PowerShell" ``` powershell - Invoke-RestMethod "ntfy.sh/mywebhook/trigger" + Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/trigger" ``` === "Python" @@ -822,7 +778,7 @@ Here's an example with a custom message, tags and a priority: === "PowerShell" ``` powershell - Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" + Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull" ``` === "Python" @@ -927,29 +883,25 @@ is the only required one: === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh" - Body = @{ - Topic = "mytopic" - Title = "Low disk space alert" - Message = "Disk space is low at 5.1 GB" - Priority = 4 - Attach = "https://filesrv.lan/space.jpg" - FileName = "diskspace.jpg" - Tags = @("warning", "cd") - Click = "https://homecamera.lan/xasds1h2xsSsa/" - Actions = ConvertTo-JSON @( - @{ - Action = "view" - Label = "Admin panel" - URL = "https://filesrv.lan/admin" - } + $uri = "https://ntfy.sh" + $body = @{ + topic = "mytopic" + title = "Low disk space alert" + message = "Disk space is low at 5.1 GB" + priority = 4 + attach = "https://filesrv.lan/space.jpg" + filename = "diskspace.jpg" + tags = @("warning", "cd") + click = "https://homecamera.lan/xasds1h2xsSsa/" + actions = @( + @{ + action = "view" + label = "Admin panel" + url = "https://filesrv.lan/admin" + } ) - } - ContentType = "application/json" - } - Invoke-RestMethod @Request + } | ConvertTo-Json + Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing ``` === "Python" @@ -1004,11 +956,9 @@ all the supported fields: | `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | | `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | | `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) | -| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | | `filename` | - | *string* | `file.jpg` | File name of the attachment | | `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | | `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | -| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | ## Action buttons _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -1111,15 +1061,10 @@ As an example, here's how you can create the above notification using this forma === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/myhome" - Headers = @{ - Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" - } - Body = "You left the house. Turn down the A/C?" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/myhome" + $headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" } + $body = "You left the house. Turn down the A/C?" + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -1141,13 +1086,7 @@ As an example, here's how you can create the above notification using this forma ] ])); ``` - -!!! info - ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). - If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including actions) - as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), - or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). - + #### Using a JSON array Alternatively, the same actions can be defined as **JSON array**, if the notification is defined as part of the JSON body (see [publish as JSON](#publish-as-json)): @@ -1275,30 +1214,26 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh" - Body = ConvertTo-JSON @{ - Topic = "myhome" - Message = "You left the house. Turn down the A/C?" - Actions = @( - @{ - Action = "view" - Label = "Open portal" - URL = "https://home.nest.com/" - Clear = $true - }, - @{ - Action = "http" - Label = "Turn down" - URL = "https://api.nest.com/" - Body = '{"temperature": 65}' - } + $uri = "https://ntfy.sh" + $body = @{ + topic = "myhome" + message = "You left the house. Turn down the A/C?" + actions = @( + @{ + action = "view" + label = "Open portal" + url = "https://home.nest.com/" + clear = $true + }, + @{ + action = "http" + label = "Turn down" + url = "https://api.nest.com/" + body = '{"temperature": 65}' + } ) - } - ContentType = "application/json" - } - Invoke-RestMethod @Request + } | ConvertTo-Json + Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing ``` === "Python" @@ -1357,7 +1292,7 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific ``` The required/optional fields for each action depend on the type of the action itself. Please refer to -[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), and [`http` action](#send-http-request) +[`view` action](#open-websiteapp), [`broadcasst` action](#send-android-broadcast), and [`http` action](#send-http-request) for details. ### Open website/app @@ -1423,15 +1358,10 @@ Here's an example using the [`X-Actions` header](#using-a-header): === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/myhome" - Headers = @{ - Actions = "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" - } - Body = "Somebody retweeted your tweet." - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/myhome" + $headers = @{ Actions="view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" } + $body = "Somebody retweeted your tweet." + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -1544,23 +1474,19 @@ And the same example using [JSON publishing](#publish-as-json): === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh" - Body = ConvertTo-JSON @{ - Topic = "myhome" - Message = "Somebody retweeted your tweet." - Actions = @( - @{ - Action = "view" - Label = "Open Twitter" - URL = "https://twitter.com/binwiederhier/status/1467633927951163392" - } + $uri = "https://ntfy.sh" + $body = @{ + topic = "myhome" + message = "Somebody retweeted your tweet." + actions = @( + @{ + "action"="view" + "label"="Open Twitter" + "url"="https://twitter.com/binwiederhier/status/1467633927951163392" + } ) - } - ContentType = "application/json" - } - Invoke-RestMethod @Request + } | ConvertTo-Json + Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing ``` === "Python" @@ -1674,15 +1600,10 @@ Here's an example using the [`X-Actions` header](#using-a-header): === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/wifey" - Headers = @{ - Actions = "broadcast, Take picture, extras.cmd=pic, extras.camera=front" - } - Body = "Your wife requested you send a picture of yourself." - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/wifey" + $headers = @{ Actions="broadcast, Take picture, extras.cmd=pic, extras.camera=front" } + $body = "Your wife requested you send a picture of yourself." + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -1812,26 +1733,23 @@ And the same example using [JSON publishing](#publish-as-json): ``` powershell # Powershell requires the 'Depth' argument to equal 3 here to expand 'Extras', # otherwise it will read System.Collections.Hashtable in the returned JSON - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh" - Body = @{ - Topic = "wifey" - Message = "Your wife requested you send a picture of yourself." - Actions = ConvertTo-Json -Depth 3 @( - @{ - Action = "broadcast" - Label = "Take picture" - Extras = @{ - CMD ="pic" - Camera = "front" + + $uri = "https://ntfy.sh" + $body = @{ + topic = "wifey" + message = "Your wife requested you send a picture of yourself." + actions = @( + @{ + action = "broadcast" + label = "Take picture" + extras = @{ + cmd ="pic" + camera = "front" + } } - } ) - } - ContentType = "application/json" - } - Invoke-RestMethod @Request + } | ConvertTo-Json -Depth 3 + Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing ``` === "Python" @@ -1943,15 +1861,10 @@ Here's an example using the [`X-Actions` header](#using-a-header): === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/myhome" - Headers = @{ - Actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" - } - Body = "Garage door has been open for 15 minutes. Close it?" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/myhome" + $headers = @{ Actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" } + $body = "Garage door has been open for 15 minutes. Close it?" + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -2092,28 +2005,24 @@ And the same example using [JSON publishing](#publish-as-json): # Powershell requires the 'Depth' argument to equal 3 here to expand 'headers', # otherwise it will read System.Collections.Hashtable in the returned JSON - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh" - Body = @{ - Topic = "myhome" - Message = "Garage door has been open for 15 minutes. Close it?" - Actions = ConvertTo-Json -Depth 3 @( - @{ - Action = "http" - Label = "Close door" - URL = "https://api.mygarage.lan/" - Method = "PUT" - Headers = @{ - Authorization = "Bearer zAzsx1sk.." + $uri = "https://ntfy.sh" + $body = @{ + topic = "myhome" + message = "Garage door has been open for 15 minutes. Close it?" + actions = @( + @{ + action = "http" + label = "Close door" + url = "https://api.mygarage.lan/" + method = "PUT" + headers = @{ + Authorization = "Bearer zAzsx1sk.." + } + body = '{"action": "close"}' } - Body = ConvertTo-JSON @{Action = "close"} - } ) - } - ContentType = "application/json" - } - Invoke-RestMethod @Request + } | ConvertTo-Json -Depth 3 + Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing ``` === "Python" @@ -2240,13 +2149,10 @@ Here's an example that will open Reddit when the notification is clicked: === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/reddit_alerts" - Headers = @{ Click="https://www.reddit.com/message/messages" } - Body = "New messages on Reddit" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/reddit_alerts" + $headers = @{ Click="https://www.reddit.com/message/messages" } + $body = "New messages on Reddit" + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -2415,12 +2321,9 @@ Here's an example showing how to attach an APK file: === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/mydownloads" - Headers = @{ Attach="https://f-droid.org/F-Droid.apk" } - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/mydownloads" + $headers = @{ Attach="https://f-droid.org/F-Droid.apk" } + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -UseBasicParsing ``` === "Python" @@ -2511,17 +2414,12 @@ Here's an example showing how to include an icon: === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/tvshows" - Headers = @{ - Title = "Kodi: Resuming Playback" - Tags = "arrow_forward" - Icon = "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" - } - Body = "The Wire, S01E01" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/tvshows" + $headers = @{ Title"="Kodi: Resuming Playback" + Tags="arrow_forward" + Icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" } + $body = "The Wire, S01E01" + Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing ``` === "Python" @@ -2627,18 +2525,13 @@ that, your IP address appears in the e-mail body. This is to prevent abuse. === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/alerts" - Headers = @{ - Title = "Low disk space alert" - Priority = "high" - Tags = "warning,skull,backup-host,ssh-login") - Email = "phil@example.com" - } - Body = "Unknown login from 5.31.23.83 to backups.example.com" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/alerts" + $headers = @{ Title"="Low disk space alert" + Priority="high" + Tags="warning,skull,backup-host,ssh-login") + Email="phil@example.com" } + $body = "Unknown login from 5.31.23.83 to backups.example.com" + Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -UseBasicParsing ``` === "Python" @@ -2689,11 +2582,6 @@ format is: ntfy-$topic@ntfy.sh ``` -If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, e-mail publishing won't work without providing an authorized access token. That will change the format of the e-mail's recipient address to -``` -ntfy-$topic+$token@ntfy.sh -``` - As of today, e-mail publishing only supports adding a [message title](#message-title) (the e-mail subject). Tags, priority, delay and other features are not supported (yet). Here's an example that will publish a message with the title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://ntfy.sh/sometopic)): @@ -2703,133 +2591,6 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
Publishing a message via e-mail
-## Phone calls -_Supported on:_ :material-android: :material-apple: :material-firefox: - -You can use ntfy to call a phone and **read the message out loud using text-to-speech**. -Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have -the ntfy app installed on their phone. - -**Phone numbers have to be previously verified** (via the [web app](https://ntfy.sh/account)), so this feature is -**only available to authenticated users** (no anonymous phone calls). To forward a message as a voice call, pass a phone -number in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`. -You may also simply pass `yes` as a value to pick the first of your verified phone numbers. -On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans. - -
- ![phone number verification](static/img/web-phone-verify.png) -
Phone number verification in the web app
-
- -As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll -be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues). - -!!! info - You are responsible for the message content, and **you must abide by the [Twilio Acceptable Use Policy](https://www.twilio.com/en-us/legal/aup)**. - This particularly means that you must not use this feature to send unsolicited messages, or messages that are illegal or - violate the rights of others. Please read the policy for details. Failure to do so may result in your account being suspended or terminated. - -Here's how you use it: - -=== "Command line (curl)" - ``` - curl \ - -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ - -H "Call: +12223334444" \ - -d "Your garage seems to be on fire. You should probably check that out." \ - ntfy.sh/alerts - ``` - -=== "ntfy CLI" - ``` - ntfy publish \ - --token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ - --call=+12223334444 \ - alerts "Your garage seems to be on fire. You should probably check that out." - ``` - -=== "HTTP" - ``` http - POST /alerts HTTP/1.1 - Host: ntfy.sh - Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 - Call: +12223334444 - - Your garage seems to be on fire. You should probably check that out. - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.sh/alerts', { - method: 'POST', - body: "Your garage seems to be on fire. You should probably check that out.", - headers: { - 'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2', - 'Call': '+12223334444' - } - }) - ``` - -=== "Go" - ``` go - req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", - strings.NewReader("Your garage seems to be on fire. You should probably check that out.")) - req.Header.Set("Call", "+12223334444") - req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2") - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/alerts" - Headers = @{ - Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" - Call = "+12223334444" - } - Body = "Your garage seems to be on fire. You should probably check that out." - } - Invoke-RestMethod @Request - ``` - -=== "Python" - ``` python - requests.post("https://ntfy.sh/alerts", - data="Your garage seems to be on fire. You should probably check that out.", - headers={ - "Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", - "Call": "+12223334444" - }) - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => - "Content-Type: text/plain\r\n" . - "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\r\n" . - "Call: +12223334444", - 'content' => 'Your garage seems to be on fire. You should probably check that out.' - ] - ])); - ``` - -Here's what a phone call from ntfy sounds like: - - - -Audio transcript: - -> You have a notification from ntfy on topic alerts. -> Message: Your garage seems to be on fire. You should probably check that out. End message. -> This message was sent by user phil. It will be repeated up to three times. - ## Authentication Depending on whether the server is configured to support [access control](config.md#access-control), some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. @@ -2843,7 +2604,7 @@ To publish/subscribe to protected topics, you can: When using Basic auth, base64 only encodes username and password. It **is not encrypting it**. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing your password. -### Username + password +### Username & password The simplest way to authenticate against a ntfy server is to use [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication). Here's an example with a user `testuser` and password `fakepassword`: @@ -2891,37 +2652,14 @@ Here's an example with a user `testuser` and password `fakepassword`: http.DefaultClient.Do(req) ``` -=== "PowerShell 7+" +=== "PowerShell" ``` powershell - # Get the credentials from the user - $Credential = Get-Credential testuser - - # Alternatively, create a PSCredential object with the password from scratch - $Credential = [PSCredential]::new("testuser", (ConvertTo-SecureString "password" -AsPlainText -Force)) - - # Note that the Authentication parameter requires PowerShell 7 or later - $Request = @{ - Method = "POST" - URI = "https://ntfy.example.com/mysecrets" - Authentication = "Basic" - Credential = $Credential - Body = "Look ma, with auth" - } - Invoke-RestMethod @Request - ``` - -=== "PowerShell 5 and earlier" - ``` powershell - # With PowerShell 5 or earlier, we need to create the base64 username:password string ourselves - $CredentialString = "$($Credential.Username):$($Credential.GetNetworkCredential().Password)" - $EncodedCredential = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CredentialString)) - $Request = @{ - Method = "POST" - URI = "https://ntfy.example.com/mysecrets" - Headers = @{ Authorization = "Basic $EncodedCredential"} - Body = "Look ma, with auth" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.example.com/mysecrets" + $credentials = 'testuser:fakepassword' + $encodedCredentials = [convert]::ToBase64String([text.Encoding]::UTF8.GetBytes($credentials)) + $headers = @{Authorization="Basic $encodedCredentials"} + $message = "Look ma, with auth" + Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing ``` === "Python" @@ -3018,29 +2756,12 @@ with the token `tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`: http.DefaultClient.Do(req) ``` -=== "PowerShell 7+" +=== "PowerShell" ``` powershell - # With PowerShell 7 or greater, we can use the Authentication and Token parameters - $Request = @{ - Method = "POST" - URI = "https://ntfy.example.com/mysecrets" - Authorization = "Bearer" - Token = "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" - Body = "Look ma, with auth" - } - Invoke-RestMethod @Request - ``` - -=== "PowerShell 5 and earlier" - ``` powershell - # In PowerShell 5 and below, we can only send the Bearer token as a string in the Headers - $Request = @{ - Method = "POST" - URI = "https://ntfy.example.com/mysecrets" - Headers = @{ Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" } - Body = "Look ma, with auth" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.example.com/mysecrets" + $headers = @{Authorization="Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"} + $message = "Look ma, with auth" + Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing ``` === "Python" @@ -3115,16 +2836,10 @@ access token. This is primarily useful to make `curl` calls easier, e.g. `curl - === "PowerShell" ``` powershell - # Note that PSCredentials *must* have a username, so we fall back to placing the authorization in the Headers as with PowerShell 5 - $Request = @{ - Method = "POST" - URI = "https://ntfy.example.com/mysecrets" - Headers = @{ - Authorization = "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy" - } - Body = "Look ma, with auth" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.example.com/mysecrets" + $headers = @{Authorization="Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"} + $message = "Look ma, with auth" + Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing ``` === "Python" @@ -3193,12 +2908,9 @@ Here's an example using the `auth` query parameter: === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw" - Body = "Look ma, with auth" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw" + $message = "Look ma, with auth" + Invoke-RestMethod -Uri $uri -Body $message -Method "Post" -UseBasicParsing ``` === "Python" @@ -3218,7 +2930,7 @@ Here's an example using the `auth` query parameter: ])); ``` -To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see above) using +To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see anove) using **raw base64 encoding** (like base64, but strip any trailing `=`). Here's some pseudo-code that hopefully explains it better: @@ -3238,12 +2950,6 @@ The following command will generate the appropriate value for you on *nix system echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '=' ``` -For access tokens, you can use this instead: - -``` -echo -n "Bearer faketoken" | base64 | tr -d '=' -``` - ## Advanced features ### Message caching @@ -3301,13 +3007,10 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/mytopic" - Headers = @{ Cache="no" } - Body = "This message won't be stored server-side" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/mytopic" + $headers = @{ Cache="no" } + $body = "This message won't be stored server-side" + Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing ``` === "Python" @@ -3384,13 +3087,10 @@ to `no`. This will instruct the server not to forward messages to Firebase. === "PowerShell" ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/mytopic" - Headers = @{ Firebase="no" } - Body = "This message won't be forwarded to FCM" - } - Invoke-RestMethod @Request + $uri = "https://ntfy.sh/mytopic" + $headers = @{ Firebase="no" } + $body = "This message won't be forwarded to FCM" + Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing ``` === "Python" @@ -3456,34 +3156,22 @@ There are a few limitations to the API to prevent abuse and to keep the server h are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into, but just in case, let's list them all: -| Limit | Description | -|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | -| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. | -| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. | -| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. | -| **Phone calls** | By default, the server does not allow any phone calls, except for users with a tier that has a call limit. | -| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | -| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. | -| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | -| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. | -| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | - -These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing -a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above. +| Limit | Description | +|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | +| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. | +| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. | +| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | +| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. | +| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | +| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. | +| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | ## List of all parameters -The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive** -when used in **HTTP headers**, and must be **lowercase** when used as **query parameters in the URL**. They are listed in the -table in their canonical form. +The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**, +and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form. -!!! info - ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). - If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any - header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), - or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). - -| Parameter | Aliases | Description | +| Parameter | Aliases (case-insensitive) | Description | |-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------| | `X-Message` | `Message`, `m` | Main body of the message as shown in the notification | | `X-Title` | `Title`, `t` | [Message title](#message-title) | @@ -3496,7 +3184,6 @@ table in their canonical form. | `X-Icon` | `Icon` | URL to use as notification [icon](#icons) | | `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client | | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) | -| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | | `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps | diff --git a/docs/releases.md b/docs/releases.md index 0e93b67..82cd2db 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,216 +2,6 @@ Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). -## ntfy server v2.5.0 -Released May 18, 2023 - -This release brings a number of new features, including support for text-to-speech style [phone calls](publish.md#phone-calls), -an admin API to manage users and ACL (currently in beta, and hence undocumented), and support for authorized access to -upstream servers via the `upstream-access-token` config option. - -❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) -and [Liberapay](https://en.liberapay.com/ntfy/), or by buying a [paid plan via the web app](https://ntfy.sh/app) (20% off -if you use promo code `MYTOPIC`). ntfy will always remain open source. - -**Features:** - -* Support for text-to-speech style [phone calls](publish.md#phone-calls) using the `X-Call` header (no ticket) -* Admin API to manage users and ACL, `v1/users` + `v1/users/access` (intentionally undocumented as of now, [#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket) -* Added `upstream-access-token` config option to allow authorized access to upstream servers (no ticket) - -**Bug fixes + maintenance:** - -* Removed old ntfy website from ntfy entirely (no ticket) -* Make emoji lookup for emails more efficient ([#725](https://github.com/binwiederhier/ntfy/pull/725), thanks to [@adamantike](https://github.com/adamantike)) -* Fix potential subscriber ID clash ([#712](https://github.com/binwiederhier/ntfy/issues/712), thanks to [@peterbourgon](https://github.com/peterbourgon) for reporting, and [@dropdevrahul](https://github.com/dropdevrahul) for fixing) -* Support for `quoted-printable` in incoming emails ([#719](https://github.com/binwiederhier/ntfy/pull/719), thanks to [@Aerion](https://github.com/Aerion)) -* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing) -* Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting) - -## ntfy server v2.4.0 -Released Apr 26, 2023 - -This release adds a tiny `v1/stats` endpoint to expose how many messages have been published, and adds suport to encode the `X-Title`, -`X-Message` and `X-Tags` header as RFC 2047. It's a pretty small release, and mainly enables the release of the new ntfy.sh website. - -❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) -and [Liberapay](https://en.liberapay.com/ntfy/), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy -will always remain open source. - -**Features:** - -* [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) can now be installed via Homebrew (thanks to [@Moulick](https://github.com/Moulick)) -* Added `v1/stats` endpoint to expose messages stats (no ticket) -* Support [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2) encoded headers (no ticket, honorable mention to [mqttwarn](https://github.com/jpmens/mqttwarn/pull/638) and [@amotl](https://github.com/amotl)) - -**Bug fixes + maintenance:** - -* Hide country flags on Windows ([#606](https://github.com/binwiederhier/ntfy/issues/606), thanks to [@cmeis](https://github.com/cmeis) for reporting, and to [@pokej6](https://github.com/pokej6) for fixing it) -* `ntfy sub` now uses default auth credentials as defined in `client.yml` ([#698](https://github.com/binwiederhier/ntfy/issues/698), thanks to [@CrimsonFez](https://github.com/CrimsonFez) for reporting, and to [@wunter8](https://github.com/wunter8) for fixing it) - -**Documentation:** - -* Updated PowerShell examples ([#697](https://github.com/binwiederhier/ntfy/pull/697), thanks to [@Natfan](https://github.com/Natfan)) - -**Additional languages:** - -* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/Shjosan/)) - -## ntfy server v2.3.1 -Released March 30, 2023 - -This release disables server-initiated polling of iOS devices entirely, thereby eliminating the thundering herd problem -on ntfy.sh that we observe every 20 minutes. The polling was never strictly necessary, and has actually caused duplicate -delivery issues as well, so disabling it should not have any negative effects. iOS users, please reach out via Discord -or Matrix if there are issues. - -**Bug fixes + maintenance:** - -* Disable iOS polling entirely ([#677](https://github.com/binwiederhier/ntfy/issues/677)/[#509](https://github.com/binwiederhier/ntfy/issues/509)) - -## ntfy server v2.3.0 -Released March 29, 2023 - -This release primarily fixes an issue with delayed messages, and it adds support for Go's profiler (if enabled), which -will allow investigating usage spikes in more detail. There will likely be a follow-up release this week to fix the -actual spikes [caused by iOS devices](https://github.com/binwiederhier/ntfy/issues/677). - -**Features:** - -* ntfy now supports Go's `pprof` profiler, if enabled (relates to [#677](https://github.com/binwiederhier/ntfy/issues/677)) - -**Bug fixes + maintenance:** - -* Fix delayed message sending from authenticated users ([#679](https://github.com/binwiederhier/ntfy/issues/679)) -* Fixed plural for Polish and other translations ([#678](https://github.com/binwiederhier/ntfy/pull/678), thanks to [@bmoczulski](https://github.com/bmoczulski)) - -## ntfy server v2.2.0 -Released March 17, 2023 - -With this release, ntfy is now able to expose metrics via a `/metrics` endpoint for [Prometheus](https://prometheus.io/), if enabled. -The endpoint exposes about 20 different counters and gauges, from the number of published messages and emails, to active subscribers, -visitors and topics. If you'd like more metrics, pop in the Discord/Matrix or file an issue on GitHub. - -On top of this, you can now use access tokens in the ntfy CLI (defined in the `client.yml` file), fixed a bug in `ntfy subscribe`, -removed the dependency on Google Fonts, and more. - -🔥 Reminder: Purchase one of three **ntfy Pro plans** for **50% off** for a limited time (if you use promo code `MYTOPIC`). -ntfy Pro gives you higher rate limits and lets you reserve topic names. [Buy through web app](https://ntfy.sh/app). - -❤️ If you don't need ntfy Pro, please consider sponsoring ntfy via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) -and [Liberapay](https://en.liberapay.com/ntfy/). ntfy will stay open source forever. - -**Features:** - -* Monitoring: ntfy now exposes a `/metrics` endpoint for [Prometheus](https://prometheus.io/) if [configured](config.md#monitoring) ([#210](https://github.com/binwiederhier/ntfy/issues/210), thanks to [@rogeliodh](https://github.com/rogeliodh) for reporting) -* You can now use tokens in `client.yml` for publishing and subscribing ([#653](https://github.com/binwiederhier/ntfy/issues/653), thanks to [@wunter8](https://github.com/wunter8)) - -**Bug fixes + maintenance:** - -* `ntfy sub --poll --from-config` will now include authentication headers from client.yml (if applicable) ([#658](https://github.com/binwiederhier/ntfy/issues/658), thanks to [@wunter8](https://github.com/wunter8)) -* Docs: Removed dependency on Google Fonts in docs ([#554](https://github.com/binwiederhier/ntfy/issues/554), thanks to [@bt90](https://github.com/bt90) for reporting, and [@ozskywalker](https://github.com/ozskywalker) for implementing) -* Increase allowed auth failure attempts per IP address to 30 (no ticket) -* Web app: Increase maximum incremental backoff retry interval to 2 minutes (no ticket) - -**Documentation:** - -* Make query parameter description more clear ([#630](https://github.com/binwiederhier/ntfy/issues/630), thanks to [@bbaa-bbaa](https://github.com/bbaa-bbaa) for reporting, and to [@wunter8](https://github.com/wunter8) for a fix) - -## ntfy server v2.1.2 -Released March 4, 2023 - -This is a hotfix release, mostly to combat the ridiculous amount of Matrix requests with invalid/dead pushkeys, and the -corresponding HTTP 507 responses the ntfy.sh server is sending out. We're up to >600k HTTP 507 responses per day 🤦. This -release solves this issue by rejecting Matrix pushkeys, if nobody has subscribed to the corresponding topic for 12 hours. - -The release furthermore reverts the default rate limiting behavior for UnifiedPush to be publisher-based, and introduces -a flag to enable [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) for high volume servers. - -**Features:** - -* Support SMTP servers without auth ([#645](https://github.com/binwiederhier/ntfy/issues/645), thanks to [@Sharknoon](https://github.com/Sharknoon) for reporting) - -**Bug fixes + maintenance:** - -* Token auth doesn't work if default user credentials are defined in `client.yml` ([#650](https://github.com/binwiederhier/ntfy/issues/650), thanks to [@Xinayder](https://github.com/Xinayder)) -* Add `visitor-subscriber-rate-limiting` flag to allow enabling [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) (off by default now, [#649](https://github.com/binwiederhier/ntfy/issues/649)/[#655](https://github.com/binwiederhier/ntfy/pull/655), thanks to [@barathrm](https://github.com/barathrm) for reporting, and to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design) -* Reject Matrix pushkey after 12 hours of inactivity on a topic, if `visitor-subscriber-rate-limiting` is enabled ([#643](https://github.com/binwiederhier/ntfy/pull/643), thanks to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design) - -**Additional languages:** - -* Danish (thanks to [@Andersbiha](https://hosted.weblate.org/user/Andersbiha/)) - -## ntfy server v2.1.1 -Released March 1, 2023 - -This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work, -**today I am finally launching the paid plans on ntfy.sh** 🥳 🎉. - -You are now able to purchase one of three plans that'll give you **higher rate limits** (messages, emails, attachment sizes, ...), -as well as the ability to **reserve topic names** for your personal use, while at the same time supporting me and the -ntfy open source project ❤️. You can check out the pricing, and [purchase plans through the web app](https://ntfy.sh/app) (use -promo code `MYTOPIC` for a **50% discount**, limited time only). - -And as I've said many times: Do not worry. **ntfy will always stay open source**, and that includes all features. There -are no closed-source features. So if you'd like to run your own server, you can! - -**Bug fixes + maintenance:** - -* Fix panic when using Firebase without users ([#641](https://github.com/binwiederhier/ntfy/issues/641), thanks to [u/heavybell](https://www.reddit.com/user/heavybell/) for reporting) -* Remove health check from `Dockerfile` and [document it](config.md#health-checks) ([#635](https://github.com/binwiederhier/ntfy/issues/635), thanks to [@Andersbiha](https://github.com/Andersbiha)) -* Upgrade dialog: Disable submit button for free tier (no ticket) -* Allow multiple `log-level-overrides` on the same field (no ticket) -* Actually remove `ntfy publish --env-topic` flag (as per [deprecations](deprecations.md), no ticket) -* Added `billing-contact` config option (no ticket) - -## ntfy server v2.1.0 -Released February 25, 2023 - -This release changes the way UnifiedPush (UP) topics are rate limited from publisher-based rate limiting to subscriber-based -rate limiting. This allows UP application servers to send higher volumes, since the subscribers carry the rate limits. -However, it also means that UP clients have to subscribe to a topic first before they are allowed to publish. If they do -no, clients will receive an HTTP 507 response from the server. - -We also fixed another issue with UnifiedPush: Some Mastodon servers were sending unsupported `Authorization` headers, -which ntfy rejected with an HTTP 401. We now ignore unsupported header values. - -As of this release, ntfy also supports sending emails to protected topics, and it ships code to support annual billing -cycles (not live yet). - -As part of this release, I also enabled sign-up and login (free accounts only), and I also started reducing the rate -limits for anonymous & free users a bit. With the next release and the launch of the paid plan, I'll reduce the limits -a bit more. For 90% of users, you should not feel the difference. - -**Features:** - -* UnifiedPush: Subscriber-based rate limiting for `up*` topics ([#584](https://github.com/binwiederhier/ntfy/pull/584)/[#609](https://github.com/binwiederhier/ntfy/pull/609)/[#633](https://github.com/binwiederhier/ntfy/pull/633), thanks to [@karmanyaahm](https://github.com/karmanyaahm)) -* Support for publishing to protected topics via email with access tokens ([#612](https://github.com/binwiederhier/ntfy/pull/621), thanks to [@tamcore](https://github.com/tamcore)) -* Support for base64-encoded and nested multipart emails ([#610](https://github.com/binwiederhier/ntfy/issues/610), thanks to [@Robert-litts](https://github.com/Robert-litts)) -* Payments: Add support for annual billing intervals (no ticket) - -**Bug fixes + maintenance:** - -* Web: Do not disable "Reserve topic" checkbox for admins (no ticket, thanks to @xenrox for reporting) -* UnifiedPush: Treat non-Basic/Bearer `Authorization` header like header was not sent ([#629](https://github.com/binwiederhier/ntfy/issues/629), thanks to [@Boebbele](https://github.com/Boebbele) and [@S1m](https://github.com/S1m) for reporting) - -**Documentation:** - -* Added example for [Traccar](https://ntfy.sh/docs/examples/#traccar) ([#631](https://github.com/binwiederhier/ntfy/pull/631), thanks to [tamcore](https://github.com/tamcore)) - -**Additional languages:** - -* Arabic (thanks to [@ButterflyOfFire](https://hosted.weblate.org/user/ButterflyOfFire/)) - -## ntfy server v2.0.1 -Released February 17, 2023 - -This is a quick bugfix release to address a panic that happens when `attachment-cache-dir` is not set. - -**Bug fixes + maintenance:** - -* Avoid panic in manager when `attachment-cache-dir` is not set ([#617](https://github.com/binwiederhier/ntfy/issues/617), thanks to [@ksurl](https://github.com/ksurl)) -* Ensure that calls to standard logger `log.Println` also output JSON (no ticket) - ## ntfy server v2.0.0 Released February 16, 2023 @@ -274,11 +64,6 @@ going. It'll only make ntfy better. * User account signup, login, topic reservations, access tokens, tiers etc. ([#522](https://github.com/binwiederhier/ntfy/issues/522)) * `OPTIONS` method calls are not serviced when the UI is disabled ([#598](https://github.com/binwiederhier/ntfy/issues/598), thanks to [@enticedwanderer](https://github.com/enticedwanderer) for reporting) -**Special thanks:** - -A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback, -suggestions, and bug reports. Thank you, @nwithan8, @deadcade, @xenrox, @cmeis, @wunter8 and the others who I forgot. - ## ntfy server v1.31.0 Released February 14, 2023 @@ -310,6 +95,11 @@ breaking-change upgrade, which required some work to get working again. * Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/)) +**Special thanks:** + +A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback, +suggestions, and bug reports. Thank you, @nwithan8, @deadcade, and @xenrox. + ## ntfy server v1.30.1 Released December 23, 2022 🎅 @@ -1201,36 +991,3 @@ Released Dec 28, 2021 ## Older releases For older releases, check out the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). - -## Not released yet - -### ntfy Android app v1.16.1 (UNRELEASED) - -**Features:** - -* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) - -**Bug fixes + maintenance:** - -* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8)) -* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing) -* Bumped all dependencies to the latest versions (no ticket) - -**Additional languages:** - -* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/)) - -### ntfy server v2.6.0 (UNRELEASED) - -**Bug fixes:** - -* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting) -* Do not forward poll requests for UnifiedPush messages (no ticket, thanks to NoName for reporting) -* Fix `ntfy pub %` segfaulting ([#760](https://github.com/binwiederhier/ntfy/issues/760), thanks to [@clesmian](https://github.com/clesmian) for reporting) - -**Maintenance:** - -* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost)) -* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost)) -* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost)) -* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost)) diff --git a/docs/static/audio/ntfy-phone-call.mp3 b/docs/static/audio/ntfy-phone-call.mp3 deleted file mode 100644 index 0cace65..0000000 Binary files a/docs/static/audio/ntfy-phone-call.mp3 and /dev/null differ diff --git a/docs/static/audio/ntfy-phone-call.ogg b/docs/static/audio/ntfy-phone-call.ogg deleted file mode 100644 index cbbf6b6..0000000 Binary files a/docs/static/audio/ntfy-phone-call.ogg and /dev/null differ diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css index 3c53aed..0b239e1 100644 --- a/docs/static/css/extra.css +++ b/docs/static/css/extra.css @@ -2,15 +2,13 @@ --md-primary-fg-color: #338574; --md-primary-fg-color--light: #338574; --md-primary-fg-color--dark: #338574; - --md-footer-bg-color: #353744; - --md-text-font: "Roboto"; - --md-code-font: "Roboto Mono"; } .md-header__button.md-logo :is(img, svg) { width: unset !important; } + .md-header__topic:first-child { font-weight: 400; } @@ -71,18 +69,7 @@ figure video { } .remove-md-box td { - padding: 0 10px; -} - -.emoji-table .c { - vertical-align: middle !important; -} - -.emoji-table .e { - font-size: 2.5em; - padding: 0 2px !important; - text-align: center !important; - vertical-align: middle !important; + padding: 0 10px } /* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */ @@ -160,57 +147,3 @@ figure video { .lightbox .close-lightbox:hover::before { background-color: #fff; } - -/* roboto-300 - latin */ -@font-face { - font-display: swap; - font-family: 'Roboto'; - font-style: normal; - font-weight: 300; - src: url('../fonts/roboto-v30-latin-300.woff2') format('woff2'); -} - -/* roboto-regular - latin */ -@font-face { - font-display: swap; - font-family: 'Roboto'; - font-style: normal; - font-weight: 400; - src: url('../fonts/roboto-v30-latin-regular.woff2') format('woff2'); -} - -/* roboto-italic - latin */ -@font-face { - font-display: swap; - font-family: 'Roboto'; - font-style: italic; - font-weight: 400; - src: url('../fonts/roboto-v30-latin-italic.woff2') format('woff2'); -} - -/* roboto-500 - latin */ -@font-face { - font-display: swap; - font-family: 'Roboto'; - font-style: normal; - font-weight: 500; - src: url('../fonts/roboto-v30-latin-500.woff2') format('woff2'); -} - -/* roboto-700 - latin */ -@font-face { - font-display: swap; - font-family: 'Roboto'; - font-style: normal; - font-weight: 700; - src: url('../fonts/roboto-v30-latin-700.woff2') format('woff2'); -} - -/* roboto-mono - latin */ -@font-face { - font-display: swap; - font-family: 'Roboto Mono'; - font-style: normal; - font-weight: 400; - src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2'); -} diff --git a/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 b/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 deleted file mode 100644 index f8894ba..0000000 Binary files a/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 and /dev/null differ diff --git a/docs/static/fonts/roboto-v30-latin-300.woff2 b/docs/static/fonts/roboto-v30-latin-300.woff2 deleted file mode 100644 index 6068138..0000000 Binary files a/docs/static/fonts/roboto-v30-latin-300.woff2 and /dev/null differ diff --git a/docs/static/fonts/roboto-v30-latin-500.woff2 b/docs/static/fonts/roboto-v30-latin-500.woff2 deleted file mode 100644 index 29342a8..0000000 Binary files a/docs/static/fonts/roboto-v30-latin-500.woff2 and /dev/null differ diff --git a/docs/static/fonts/roboto-v30-latin-700.woff2 b/docs/static/fonts/roboto-v30-latin-700.woff2 deleted file mode 100644 index 771fbec..0000000 Binary files a/docs/static/fonts/roboto-v30-latin-700.woff2 and /dev/null differ diff --git a/docs/static/fonts/roboto-v30-latin-italic.woff2 b/docs/static/fonts/roboto-v30-latin-italic.woff2 deleted file mode 100644 index e1b7a79..0000000 Binary files a/docs/static/fonts/roboto-v30-latin-italic.woff2 and /dev/null differ diff --git a/docs/static/fonts/roboto-v30-latin-regular.woff2 b/docs/static/fonts/roboto-v30-latin-regular.woff2 deleted file mode 100644 index 020729e..0000000 Binary files a/docs/static/fonts/roboto-v30-latin-regular.woff2 and /dev/null differ diff --git a/docs/static/img/android-screenshot-logs.jpg b/docs/static/img/android-screenshot-logs.jpg deleted file mode 100644 index e5f1d8e..0000000 Binary files a/docs/static/img/android-screenshot-logs.jpg and /dev/null differ diff --git a/docs/static/img/favicon.ico b/docs/static/img/favicon.ico deleted file mode 100644 index 857fa54..0000000 Binary files a/docs/static/img/favicon.ico and /dev/null differ diff --git a/docs/static/img/favicon.png b/docs/static/img/favicon.png new file mode 100644 index 0000000..92312fe Binary files /dev/null and b/docs/static/img/favicon.png differ diff --git a/docs/static/img/grafana-dashboard.png b/docs/static/img/grafana-dashboard.png deleted file mode 100644 index 6cb12c8..0000000 Binary files a/docs/static/img/grafana-dashboard.png and /dev/null differ diff --git a/docs/static/img/web-logs.png b/docs/static/img/web-logs.png deleted file mode 100644 index 2dfebdc..0000000 Binary files a/docs/static/img/web-logs.png and /dev/null differ diff --git a/docs/static/img/web-phone-verify.png b/docs/static/img/web-phone-verify.png deleted file mode 100644 index 335aeef..0000000 Binary files a/docs/static/img/web-phone-verify.png and /dev/null differ diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md index 58da975..b9d72b4 100644 --- a/docs/subscribe/api.md +++ b/docs/subscribe/api.md @@ -319,7 +319,7 @@ format of the message. It's very straight forward: |--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| | `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | | `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | -| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent | +| `expires` | ✔️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted | | `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` | | `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | | `message` | - | *string* | `Some message` | Message body; always present in `message` events | diff --git a/docs/subscribe/cli.md b/docs/subscribe/cli.md index 59cfc8e..f1f9e76 100644 --- a/docs/subscribe/cli.md +++ b/docs/subscribe/cli.md @@ -254,13 +254,13 @@ I hope this shows how powerful this command is. Here's a short video that demons
Execute all the things
-If most (or all) of your subscriptions use the same credentials, you can set defaults in `client.yml`. Use `default-user` and `default-password` or `default-token` (but not both). -You can also specify a `default-command` that will run when a message is received. If a subscription does not include credentials to use or does not have a command, the defaults -will be used, otherwise, the subscription settings will override the defaults. +If most (or all) of your subscription usernames, passwords, and commands are the same, you can specify a `default-user`, `default-password`, and `default-command` at the top of the +`client.yml`. If a subscription does not specify a username/password to use or does not have a command, the defaults will be used, otherwise, the subscription settings will +override the defaults. !!! warning - Because the `default-user`, `default-password`, and `default-token` will be sent for each topic that does not have its own username/password (even if the topic does not - require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password. + Because the `default-user` and `default-password` will be sent for each topic that does not have its own username/password (even if the topic does not require authentication), + be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password. ### Using the systemd service You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service)) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index d37561c..0000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -1,131 +0,0 @@ -# Troubleshooting -This page lists a few suggestions of what to do when things don't work as expected. This is not a complete list. -If this page does not help, feel free to drop by the [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org) -and ask there. We're happy to help. - -## ntfy server -If you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing -in the server. You can find detailed instructions in the [Logging & Debugging](config.md#logging-debugging) section, but it ultimately -boils down to setting `log-level: debug` or `log-level: trace` in the `server.yml` file: - -=== "server.yml (debug)" - ``` yaml - log-level: debug - ``` - -=== "server.yml (trace)" - ``` yaml - log-level: trace - ``` - -If you're using environment variables, set `NTFY_LOG_LEVEL=debug` (or `trace`) instead. You can also pass `--debug` or `--trace` -to the `ntfy serve` command, e.g. `ntfy serve --trace`. If you're using systemd (i.e. `systemctl`) to run ntfy, you can look at -the logs using `journalctl -u ntfy -f`. The logs will look something like this: - -=== "Example logs (debug)" - ``` - $ ntfy serve --debug - 2023/03/20 14:45:38 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is DEBUG (tag=startup) - 2023/03/20 14:45:38 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter) - 2023/03/20 14:45:39 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00) - 2023/03/20 14:45:39 DEBUG HTTP request started (http_method=POST, http_path=/mytopic, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00) - 2023/03/20 14:45:39 DEBUG Received message (http_method=POST, http_path=/mytopic, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:45:38.319-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002132248, visitor_seen=2023-03-20T14:45:39.7-04:00) - 2023/03/20 14:45:39 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000259165, visitor_seen=2023-03-20T14:45:39.7-04:00) - 2023/03/20 14:45:39 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=2, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0004147334, visitor_seen=2023-03-20T14:45:39.7-04:00) - 2023/03/20 14:45:39 DEBUG Wrote 1 message(s) in 8.285712ms (tag=message_cache) - ... - ``` - -=== "Example logs (trace)" - ``` - $ ntfy serve --trace - 2023/03/20 14:40:42 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is TRACE (tag=startup) - 2023/03/20 14:40:42 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter) - 2023/03/20 14:40:59 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00) - 2023/03/20 14:40:59 TRACE HTTP request started (http_method=POST, http_path=/mytopic, http_request=POST /mytopic HTTP/1.1 - User-Agent: curl/7.81.0 - Accept: */* - Content-Length: 2 - Content-Type: application/x-www-form-urlencoded - - hi, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00) - 2023/03/20 14:40:59 TRACE Received message (http_method=POST, http_path=/mytopic, message_body={ - "id": "Khaup1RVclU3", - "time": 1679337659, - "expires": 1679380859, - "event": "message", - "topic": "mytopic", - "message": "hi" - }, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:40:59.893-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0001785048, visitor_seen=2023-03-20T14:40:59.893-04:00) - 2023/03/20 14:40:59 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002044368, visitor_seen=2023-03-20T14:40:59.893-04:00) - 2023/03/20 14:40:59 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=1, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000220502, visitor_seen=2023-03-20T14:40:59.893-04:00) - 2023/03/20 14:40:59 TRACE No stream or WebSocket subscribers, not forwarding (message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002369212, visitor_seen=2023-03-20T14:40:59.893-04:00) - 2023/03/20 14:41:00 DEBUG Wrote 1 message(s) in 9.529196ms (tag=message_cache) - ... - ``` - -## Android app -On Android, you can turn on logging in the settings under **Settings → Record logs**. This will store up to 1,000 log -entries, which you can then copy or upload. - -
- ![Recording logs on Android](static/img/android-screenshot-logs.jpg){ width=400 } -
Recording logs on Android
-
- -When you copy or upload the logs, you can censor them to make it easier to share them with others. ntfy will replace all -topics and hostnames with fruits. Here's an example: - -``` -This is a log of the ntfy Android app. The log shows up to 1,000 entries. -Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑. - -Device info: --- -ntfy: 1.16.0 (play) -OS: 4.19.157-perf+ -Android: 13 (SDK 33) -... - -Logs --- - -1679339199507 2023-03-20 15:06:39.507 D NtfyMainActivity Battery: ignoring optimizations = true (we want this to be true); instant subscriptions = true; remind time reached = true; banner = false -1679339199507 2023-03-20 15:06:39.507 D NtfySubscriberMgr Enqueuing work to refresh subscriber service -1679339199589 2023-03-20 15:06:39.589 D NtfySubscriberMgr ServiceStartWorker: Starting foreground service with action START (work ID: a7eeeae9-9356-40df-afbd-236e5ed10a0b) -1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService onStartCommand executed with startId: 262 -1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService using an intent with action START -1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService Refreshing subscriptions -1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Desired connections: [ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869}), ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328})] -1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Active connections: [ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328}), ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869})] -... -``` - -To get live logs, or to get more advanced access to an Android phone, you can use [adb](https://developer.android.com/studio/command-line/adb). -After you install and [enable adb debugging](https://developer.android.com/studio/command-line/adb#Enabling), you can -get detailed logs like so: - -``` -# Connect to phone (enable Wireless debugging first) -adb connect 192.168.1.137:39539 - -# Print all logs; you may have to pass the -s option -adb logcat -adb -s 192.168.1.137:39539 logcat - -# Only list ntfy logs -adb logcat --pid=$(adb shell pidof -s io.heckel.ntfy) -adb -s 192.168.1.137:39539 logcat --pid=$(adb -s 192.168.1.137:39539 shell pidof -s io.heckel.ntfy) -``` - -## Web app -The web app logs everything to the **developer console**, which you can open by **pressing the F12 key** on your -keyboard. - -
- ![Web app logs](static/img/web-logs.png) -
Web app logs in the developer console
-
- -## iOS app -Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode. diff --git a/examples/grafana-dashboard/ntfy-grafana.json b/examples/grafana-dashboard/ntfy-grafana.json deleted file mode 100644 index 11273da..0000000 --- a/examples/grafana-dashboard/ntfy-grafana.json +++ /dev/null @@ -1,2400 +0,0 @@ -{ - "__inputs": [ - { - "name": "DS_PROMETHEUS", - "label": "Prometheus", - "description": "", - "type": "datasource", - "pluginId": "prometheus", - "pluginName": "Prometheus" - } - ], - "__elements": {}, - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "9.4.3" - }, - { - "type": "datasource", - "id": "prometheus", - "name": "Prometheus", - "version": "1.0.0" - }, - { - "type": "panel", - "id": "stat", - "name": "Stat", - "version": "" - }, - { - "type": "panel", - "id": "timeseries", - "name": "Time series", - "version": "" - } - ], - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "links": [], - "liveNow": false, - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 38, - "panels": [], - "title": "Overview", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "light-green", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 4, - "x": 0, - "y": 1 - }, - "id": 36, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "last" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "9.4.3", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "ntfy_messages_published_success{job=\"$job\"}", - "legendFormat": "Messages cached", - "range": true, - "refId": "A" - } - ], - "title": "Published", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "orange", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 4, - "x": 4, - "y": 1 - }, - "id": 33, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "last" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "9.4.3", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_messages_cached_total{job=\"$job\"}", - "legendFormat": "Messages cached", - "range": true, - "refId": "A" - } - ], - "title": "Cached", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#69bfb5", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 4, - "x": 8, - "y": 1 - }, - "id": 31, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "last" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "9.4.3", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_visitors_total{job=\"$job\"}", - "legendFormat": "Visitors", - "range": true, - "refId": "A" - } - ], - "title": "Visitors", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 4, - "x": 12, - "y": 1 - }, - "id": 32, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "last" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "9.4.3", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_users_total{job=\"$job\"}", - "legendFormat": "Visitors", - "range": true, - "refId": "A" - } - ], - "title": "Users", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "blue", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 4, - "x": 16, - "y": 1 - }, - "id": 34, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "last" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "9.4.3", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_topics_total{job=\"$job\"}", - "legendFormat": "Topics", - "range": true, - "refId": "A" - } - ], - "title": "Topics", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "purple", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 4, - "x": 20, - "y": 1 - }, - "id": 35, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "last" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "9.4.3", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_subscribers_total", - "legendFormat": "Subscribers", - "range": true, - "refId": "A" - } - ], - "title": "Subscribers", - "type": "stat" - }, - { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 4 - }, - "id": 10, - "title": "Metrics", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "Number of successfully published messages, and messages that could not be published (due to rate limiting, bad formatting, etc.)", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Failed" - }, - "properties": [ - { - "id": "custom.axisColorMode", - "value": "text" - }, - { - "id": "color", - "value": { - "fixedColor": "red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 5 - }, - "id": 42, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "rate(ntfy_messages_published_success{job=\"$job\"}[$rate])", - "legendFormat": "Success", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "rate(ntfy_messages_published_failure{job=\"$job\"}[$rate])", - "hide": false, - "legendFormat": "Failed", - "range": true, - "refId": "B" - } - ], - "title": "Messages published (per second)", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "Number of messages published since last ntfy server restart", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Failed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 6, - "y": 5 - }, - "id": 4, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_messages_published_success{job=\"$job\"}", - "legendFormat": "Successful", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_messages_published_failure{job=\"$job\"}", - "hide": false, - "legendFormat": "Failed", - "range": true, - "refId": "B" - } - ], - "title": "Messages published", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "Number of messages currently stored in message cache", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 12, - "y": 5 - }, - "id": 2, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_messages_cached_total{job=\"$job\"}", - "legendFormat": "Messages in database", - "range": true, - "refId": "A" - } - ], - "title": "Messages cached", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 18, - "y": 5 - }, - "id": 14, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_visitors_total{job=\"$job\"}", - "legendFormat": "Visitors", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_topics_total{job=\"$job\"}", - "hide": false, - "legendFormat": "Topics", - "range": true, - "refId": "B" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_subscribers_total{job=\"$job\"}", - "hide": false, - "legendFormat": "Subscribers", - "range": true, - "refId": "C" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_users_total{job=\"$job\"}", - "hide": false, - "legendFormat": "Users", - "range": true, - "refId": "D" - } - ], - "title": "Visitors, subscribers, topics", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 12 - }, - "id": 43, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "sum by(job) (rate(ntfy_http_requests_total{job=\"$job\"}[$rate]))", - "legendFormat": "Requests per second", - "range": true, - "refId": "A" - } - ], - "title": "HTTP requests (per second)", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 9, - "x": 6, - "y": 12 - }, - "id": 41, - "options": { - "legend": { - "calcs": [ - "mean" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true, - "sortBy": "Mean", - "sortDesc": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "sum by(http_code) (rate(ntfy_http_requests_total{job=\"$job\", http_code!=\"200\", http_code!=\"429\", http_code!=\"507\"}[$rate]))", - "legendFormat": "{{http_code}}", - "range": true, - "refId": "A" - } - ], - "title": "HTTP errors (per second, excl. 429/507)", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 9, - "x": 15, - "y": 12 - }, - "id": 16, - "options": { - "legend": { - "calcs": [ - "mean" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true, - "sortBy": "Mean", - "sortDesc": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "sum by(ntfy_code) (rate(ntfy_http_requests_total{http_code!=\"200\", job=\"$job\"}[$rate]))", - "legendFormat": "{{http_method}} {{http_code}} {{ntfy_code}}", - "range": true, - "refId": "A" - } - ], - "title": "HTTP errors (per second, ntfy code)", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "decbytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 19 - }, - "id": 20, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_attachments_total_size{job=\"$job\"}", - "legendFormat": "Total size in MB", - "range": true, - "refId": "A" - } - ], - "title": "Attachments: Total cache size", - "transformations": [], - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": -1, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Failure" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 6, - "y": 19 - }, - "id": 27, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "rate(ntfy_firebase_published_success{job=\"$job\"}[$rate])", - "legendFormat": "Success", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "rate(ntfy_firebase_published_failure{job=\"$job\"}[$rate])", - "hide": false, - "legendFormat": "Failure", - "range": true, - "refId": "B" - } - ], - "title": "Firebase messages sent", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Rejected (HTTP 507)" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 12, - "y": 19 - }, - "id": 26, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "rate(ntfy_unifiedpush_published_success{job=\"$job\"}[$rate])", - "legendFormat": "Success", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "rate(ntfy_http_requests_total{job=\"$job\",http_code=\"507\"}[$rate])", - "hide": false, - "legendFormat": "Rejected (HTTP 507)", - "range": true, - "refId": "B" - } - ], - "title": "UnifiedPush messages", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Failure" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 18, - "y": 19 - }, - "id": 24, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "rate(ntfy_matrix_published_success{job=\"$job\"}[$rate])", - "legendFormat": "Success", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "code", - "expr": "rate(ntfy_matrix_published_failure{job=\"$job\"}[$rate])", - "hide": false, - "legendFormat": "Failure", - "range": true, - "refId": "B" - } - ], - "title": "Matrix messages published", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Failure" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 26 - }, - "id": 12, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_emails_sent_success{job=\"$job\"}", - "legendFormat": "Success", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_emails_sent_failure{job=\"$job\"}", - "hide": false, - "legendFormat": "Failure", - "range": true, - "refId": "B" - } - ], - "title": "Emails sent", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Failure" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 6, - "y": 26 - }, - "id": 22, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_emails_received_success{job=\"$job\"}", - "legendFormat": "Success", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_emails_received_failure{job=\"$job\"}", - "hide": false, - "legendFormat": "Failure", - "range": true, - "refId": "B" - } - ], - "title": "Emails received", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "ms" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 12, - "y": 26 - }, - "id": 29, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "ntfy_message_publish_duration_ms{job=\"$job\"}", - "legendFormat": "Duration", - "range": true, - "refId": "A" - } - ], - "title": "Message publish duration", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 33 - }, - "id": 8, - "panels": [], - "title": "Internals", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 34 - }, - "id": 6, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "go_goroutines{job=\"$job\"}", - "legendFormat": "Go routines", - "range": true, - "refId": "A" - } - ], - "title": "Go routines", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "log": 10, - "type": "symlog" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 6, - "y": 34 - }, - "id": 44, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "process_open_fds{job=\"$job\"}", - "legendFormat": "Open", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "process_max_fds{job=\"$job\"}", - "hide": false, - "legendFormat": "Max", - "range": true, - "refId": "B" - } - ], - "title": "File descriptors", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "decbytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 12, - "y": 34 - }, - "id": 45, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "process_resident_memory_bytes{job=\"$job\"}", - "legendFormat": "Resident memory used by ntfy (RSS)", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "editorMode": "builder", - "expr": "process_virtual_memory_bytes{job=\"$job\"}", - "hide": false, - "legendFormat": "Virtual memory used by ntfy (VSS)", - "range": true, - "refId": "B" - } - ], - "title": "Resident/virtual memory", - "type": "timeseries" - } - ], - "refresh": "10s", - "revision": 1, - "schemaVersion": 38, - "style": "dark", - "tags": [], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "definition": "label_values(ntfy_visitors_total, job)", - "hide": 0, - "includeAll": false, - "label": "Job", - "multi": false, - "name": "job", - "options": [], - "query": { - "query": "label_values(ntfy_visitors_total, job)", - "refId": "StandardVariableQuery" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 0, - "type": "query" - }, - { - "auto": false, - "auto_count": 30, - "auto_min": "10s", - "current": { - "selected": false, - "text": "30m", - "value": "30m" - }, - "description": "Average per-second rates over values from this time span", - "hide": 0, - "label": "Rate", - "name": "rate", - "options": [ - { - "selected": false, - "text": "1m", - "value": "1m" - }, - { - "selected": false, - "text": "5m", - "value": "5m" - }, - { - "selected": false, - "text": "10m", - "value": "10m" - }, - { - "selected": true, - "text": "30m", - "value": "30m" - }, - { - "selected": false, - "text": "1h", - "value": "1h" - } - ], - "query": "1m,5m,10m,30m,1h", - "queryValue": "", - "refresh": 2, - "skipUrlSync": false, - "type": "interval" - } - ] - }, - "time": { - "from": "now-24h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "ntfy App", - "uid": "TO6HgexVz", - "version": 24, - "weekStart": "" -} \ No newline at end of file diff --git a/go.mod b/go.mod index 162fd94..e248595 100644 --- a/go.mod +++ b/go.mod @@ -4,71 +4,62 @@ go 1.18 require ( cloud.google.com/go/firestore v1.9.0 // indirect - cloud.google.com/go/storage v1.30.1 // indirect + cloud.google.com/go/storage v1.29.0 // indirect github.com/BurntSushi/toml v1.2.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/emersion/go-smtp v0.16.0 - github.com/gabriel-vasile/mimetype v1.4.2 + github.com/gabriel-vasile/mimetype v1.4.1 github.com/gorilla/websocket v1.5.0 github.com/mattn/go-sqlite3 v1.14.16 github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 github.com/stretchr/testify v1.8.1 - github.com/urfave/cli/v2 v2.25.3 - golang.org/x/crypto v0.9.0 - golang.org/x/oauth2 v0.8.0 // indirect - golang.org/x/sync v0.2.0 - golang.org/x/term v0.8.0 + github.com/urfave/cli/v2 v2.24.4 + golang.org/x/crypto v0.6.0 + golang.org/x/oauth2 v0.5.0 // indirect + golang.org/x/sync v0.1.0 + golang.org/x/term v0.5.0 golang.org/x/time v0.3.0 - google.golang.org/api v0.122.0 + google.golang.org/api v0.110.0 gopkg.in/yaml.v2 v2.4.0 ) require github.com/pkg/errors v0.9.1 // indirect require ( - firebase.google.com/go/v4 v4.11.0 - github.com/prometheus/client_golang v1.15.1 - github.com/stripe/stripe-go/v74 v74.18.0 + firebase.google.com/go/v4 v4.10.0 + github.com/stripe/stripe-go/v74 v74.7.0 ) require ( - cloud.google.com/go v0.110.2 // indirect - cloud.google.com/go/compute v1.19.3 // indirect + cloud.google.com/go v0.110.0 // indirect + cloud.google.com/go/compute v1.18.0 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect - cloud.google.com/go/iam v1.0.1 // indirect - cloud.google.com/go/longrunning v0.4.2 // indirect + cloud.google.com/go/iam v0.10.0 // indirect + cloud.google.com/go/longrunning v0.4.1 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang-jwt/jwt/v4 v4.4.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-cmp v0.5.9 // indirect - github.com/google/s2a-go v0.1.3 // indirect github.com/google/uuid v1.3.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect - github.com/googleapis/gax-go/v2 v2.8.0 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/googleapis/gax-go/v2 v2.7.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.4.0 // indirect - github.com/prometheus/common v0.43.0 // indirect - github.com/prometheus/procfs v0.9.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/stretchr/objx v0.5.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/sys v0.8.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/net v0.7.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/appengine/v2 v2.0.3 // indirect - google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect - google.golang.org/grpc v1.55.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + google.golang.org/appengine/v2 v2.0.2 // indirect + google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc // indirect + google.golang.org/grpc v1.53.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bfaf339..a7f6e80 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,20 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= -cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= -cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds= -cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= +cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys= +cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= +cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY= +cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA= cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= -cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU= -cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8= -cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE= -cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ= -cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM= -cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= -firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk= -firebase.google.com/go/v4 v4.11.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE= +cloud.google.com/go/iam v0.10.0 h1:fpP/gByFs6US1ma53v7VxhvbJpO2Aapng6wabJ99MuI= +cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM= +cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM= +cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= +cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI= +cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4= +firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4= +firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -23,23 +22,11 @@ github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -51,15 +38,12 @@ github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVR github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q= +github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= +github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -68,19 +52,16 @@ github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -91,126 +72,89 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= -github.com/google/s2a-go v0.1.3 h1:FAgZmpLl/SXurPEZyCMPBIiiYeTbqfjlbdnCNTAkbGE= -github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= -github.com/googleapis/gax-go/v2 v2.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc= -github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= +github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ= +github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 h1:0uFGkScHef2Xd8g74BMHU1jFcnKEm0PzrPn4CluQ9FI= github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI= -github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= -github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= -github.com/prometheus/common v0.43.0 h1:iq+BVjvYLei5f27wiuNiB1DN6DYQkp1c8Bx0Vykh5us= -github.com/prometheus/common v0.43.0/go.mod h1:NCvr5cQIh3Y/gy73/RdVtC9r8xxrxwJnB+2lB3BxrFc= -github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= -github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw= -github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= -github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY= -github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= +github.com/stripe/stripe-go/v74 v74.7.0 h1:KHlyslQj9YOv62b1sycQ31LFj7KlqR+seHsSowAWrjc= +github.com/stripe/stripe-go/v74 v74.7.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw= +github.com/urfave/cli/v2 v2.24.4 h1:0gyJJEBYtCV87zI/x2nZCPyDxD51K6xM8SkwjHFCNEU= +github.com/urfave/cli/v2 v2.24.4/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= -golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= -golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= +golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s= +golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= -golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -218,37 +162,29 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es= -google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= +google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU= +google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine/v2 v2.0.3 h1:AyY/mipuqiyCIAqOevfmu5fMDc5/9P/QggWfCQYdkSA= -google.golang.org/appengine/v2 v2.0.3/go.mod h1:2Z0TTdcXxnHdXzmp8drrmOExUDM2WQgyT33c6JDUlJM= +google.golang.org/appengine/v2 v2.0.2 h1:MSqyWy2shDLwG7chbwBJ5uMyw6SNqJzhJHNDwYB0Akk= +google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4HoVEdMMYQR/8E= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= -google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= +google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc h1:ijGwO+0vL2hJt5gaygqP2j6PfflOBrRot0IczKbmtio= +google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= -google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag= -google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= +google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc= +google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -260,12 +196,10 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/log/event.go b/log/event.go index b4b8f59..0dd4be0 100644 --- a/log/event.go +++ b/log/event.go @@ -3,7 +3,6 @@ package log import ( "encoding/json" "fmt" - "heckel.io/ntfy/util" "log" "os" "sort" @@ -12,11 +11,11 @@ import ( ) const ( - fieldTag = "tag" - fieldError = "error" - fieldTimeTaken = "time_taken_ms" - fieldExitCode = "exit_code" - tagStdLog = "stdlog" + tagField = "tag" + errorField = "error" + timeTakenField = "time_taken_ms" + exitCodeField = "exit_code" + timestampFormat = "2006-01-02T15:04:05.999Z07:00" ) // Event represents a single log event @@ -41,39 +40,39 @@ func newEvent() *Event { // Fatal logs the event as FATAL, and exits the program with exit code 1 func (e *Event) Fatal(message string, v ...any) { - e.Field(fieldExitCode, 1).Log(FatalLevel, message, v...) + e.Field(exitCodeField, 1).maybeLog(FatalLevel, message, v...) fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr os.Exit(1) } // Error logs the event with log level error -func (e *Event) Error(message string, v ...any) *Event { - return e.Log(ErrorLevel, message, v...) +func (e *Event) Error(message string, v ...any) { + e.maybeLog(ErrorLevel, message, v...) } // Warn logs the event with log level warn -func (e *Event) Warn(message string, v ...any) *Event { - return e.Log(WarnLevel, message, v...) +func (e *Event) Warn(message string, v ...any) { + e.maybeLog(WarnLevel, message, v...) } // Info logs the event with log level info -func (e *Event) Info(message string, v ...any) *Event { - return e.Log(InfoLevel, message, v...) +func (e *Event) Info(message string, v ...any) { + e.maybeLog(InfoLevel, message, v...) } // Debug logs the event with log level debug -func (e *Event) Debug(message string, v ...any) *Event { - return e.Log(DebugLevel, message, v...) +func (e *Event) Debug(message string, v ...any) { + e.maybeLog(DebugLevel, message, v...) } // Trace logs the event with log level trace -func (e *Event) Trace(message string, v ...any) *Event { - return e.Log(TraceLevel, message, v...) +func (e *Event) Trace(message string, v ...any) { + e.maybeLog(TraceLevel, message, v...) } // Tag adds a "tag" field to the log event func (e *Event) Tag(tag string) *Event { - return e.Field(fieldTag, tag) + return e.Field(tagField, tag) } // Time sets the time field @@ -86,7 +85,7 @@ func (e *Event) Time(t time.Time) *Event { func (e *Event) Timing(f func()) *Event { start := time.Now() f() - return e.Field(fieldTimeTaken, time.Since(start).Milliseconds()) + return e.Field(timeTakenField, time.Since(start).Milliseconds()) } // Err adds an "error" field to the log event @@ -96,7 +95,7 @@ func (e *Event) Err(err error) *Event { } else if c, ok := err.(Contexter); ok { return e.With(c) } - return e.Field(fieldError, err.Error()) + return e.Field(errorField, err.Error()) } // Field adds a custom field and value to the log event @@ -108,14 +107,6 @@ func (e *Event) Field(key string, value any) *Event { return e } -// FieldIf adds a custom field and value to the log event if the given level is loggable -func (e *Event) FieldIf(key string, value any, level Level) *Event { - if e.Loggable(level) { - return e.Field(key, value) - } - return e -} - // Fields adds a map of fields to the log event func (e *Event) Fields(fields Context) *Event { if e.fields == nil { @@ -127,46 +118,39 @@ func (e *Event) Fields(fields Context) *Event { return e } -// With adds the fields of the given Contexter structs to the log event by calling their Context method -func (e *Event) With(contexters ...Contexter) *Event { +// With adds the fields of the given Contexter structs to the log event by calling their With method +func (e *Event) With(contexts ...Contexter) *Event { if e.contexters == nil { - e.contexters = contexters + e.contexters = contexts } else { - e.contexters = append(e.contexters, contexters...) + e.contexters = append(e.contexters, contexts...) } return e } -// Render returns the rendered log event as a string, or an empty string. The event is only rendered, -// if either the global log level is >= l, or if the log level in one of the overrides matches +// maybeLog logs the event to the defined output. The event is only logged, if +// either the global log level is >= l, or if the log level in one of the overrides matches // the level. // // If no overrides are defined (default), the Contexter array is not applied unless the event // is actually logged. If overrides are defined, then Contexters have to be applied in any case // to determine if they match. This is super complicated, but required for efficiency. -func (e *Event) Render(l Level, message string, v ...any) string { +func (e *Event) maybeLog(l Level, message string, v ...any) { appliedContexters := e.maybeApplyContexters() - if !e.Loggable(l) { - return "" + if !e.shouldLog(l) { + return } e.Message = fmt.Sprintf(message, v...) e.Level = l - e.Timestamp = util.FormatTime(e.time) + e.Timestamp = e.time.Format(timestampFormat) if !appliedContexters { e.applyContexters() } if CurrentFormat() == JSONFormat { - return e.JSON() + log.Println(e.JSON()) + } else { + log.Println(e.String()) } - return e.String() -} - -// Log logs the event to the defined output, or does nothing if Render returns an empty string -func (e *Event) Log(l Level, message string, v ...any) *Event { - if m := e.Render(l, message, v...); m != "" { - log.Println(m) - } - return e } // Loggable returns true if the given log level is lower or equal to the current log level @@ -208,6 +192,10 @@ func (e *Event) String() string { return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", ")) } +func (e *Event) shouldLog(l Level) bool { + return e.globalLevelWithOverride() <= l +} + func (e *Event) globalLevelWithOverride() Level { mu.RLock() l, ov := level, overrides @@ -215,13 +203,11 @@ func (e *Event) globalLevelWithOverride() Level { if e.fields == nil { return l } - for field, fieldOverrides := range ov { + for field, override := range ov { value, exists := e.fields[field] if exists { - for _, o := range fieldOverrides { - if o.value == "" || o.value == value || o.value == fmt.Sprintf("%v", value) { - return o.level - } + if override.value == "" || override.value == value || override.value == fmt.Sprintf("%v", value) { + return override.level } } } diff --git a/log/log.go b/log/log.go index 20ad615..c4934f0 100644 --- a/log/log.go +++ b/log/log.go @@ -4,7 +4,6 @@ import ( "io" "log" "os" - "strings" "sync" "time" ) @@ -13,26 +12,17 @@ import ( var ( DefaultLevel = InfoLevel DefaultFormat = TextFormat - DefaultOutput = &peekLogWriter{os.Stderr} + DefaultOutput = os.Stderr ) var ( level = DefaultLevel format = DefaultFormat - overrides = make(map[string][]*levelOverride) + overrides = make(map[string]*levelOverride) output io.Writer = DefaultOutput - filename = "" mu = &sync.RWMutex{} ) -// init sets the default log output (including log.SetOutput) -// -// This has to be explicitly called, because DefaultOutput is a peekLogWriter, -// which wraps os.Stderr. -func init() { - SetOutput(DefaultOutput) -} - // Fatal prints the given message, and exits the program func Fatal(message string, v ...any) { newEvent().Fatal(message, v...) @@ -111,17 +101,14 @@ func SetLevel(newLevel Level) { func SetLevelOverride(field string, value string, level Level) { mu.Lock() defer mu.Unlock() - if _, ok := overrides[field]; !ok { - overrides[field] = make([]*levelOverride, 0) - } - overrides[field] = append(overrides[field], &levelOverride{value: value, level: level}) + overrides[field] = &levelOverride{value: value, level: level} } // ResetLevelOverrides removes all log level overrides func ResetLevelOverrides() { mu.Lock() defer mu.Unlock() - overrides = make(map[string][]*levelOverride) + overrides = make(map[string]*levelOverride) } // CurrentFormat returns the current log format @@ -145,27 +132,28 @@ func SetFormat(newFormat Format) { func SetOutput(w io.Writer) { mu.Lock() defer mu.Unlock() - output = &peekLogWriter{w} - if f, ok := w.(*os.File); ok { - filename = f.Name() - } else { - filename = "" - } - log.SetOutput(output) + log.SetOutput(w) + output = w } // File returns the log file, if any, or an empty string otherwise func File() string { mu.RLock() defer mu.RUnlock() - return filename + if f, ok := output.(*os.File); ok { + return f.Name() + } + return "" } // IsFile returns true if the output is a non-default file func IsFile() bool { mu.RLock() defer mu.RUnlock() - return filename != "" + if _, ok := output.(*os.File); ok && output != DefaultOutput { + return true + } + return false } // DisableDates disables the date/time prefix @@ -187,20 +175,3 @@ func IsTrace() bool { func IsDebug() bool { return Loggable(DebugLevel) } - -// peekLogWriter is an io.Writer which will peek at the rendered log event, -// and ensure that the rendered output is valid JSON. This is a hack! -type peekLogWriter struct { - w io.Writer -} - -func (w *peekLogWriter) Write(p []byte) (n int, err error) { - if len(p) == 0 || p[0] == '{' || CurrentFormat() == TextFormat { - return w.w.Write(p) - } - m := newEvent().Tag(tagStdLog).Render(InfoLevel, strings.TrimSpace(string(p))) - if m == "" { - return 0, nil - } - return w.w.Write([]byte(m + "\n")) -} diff --git a/log/log_test.go b/log/log_test.go index d7ceb1c..b016494 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -4,10 +4,7 @@ import ( "bytes" "encoding/json" "github.com/stretchr/testify/require" - "io" - "log" "os" - "path/filepath" "testing" "time" ) @@ -173,96 +170,6 @@ func TestLog_LevelOverrideAny(t *testing.T) { {"time":"1970-01-01T00:00:14Z","level":"INFO","message":"this is also logged","time_taken_ms":0} ` require.Equal(t, expected, out.String()) - require.False(t, IsFile()) - require.Equal(t, "", File()) -} - -func TestLog_LevelOverride_ManyOnSameField(t *testing.T) { - t.Cleanup(resetState) - - var out bytes.Buffer - SetOutput(&out) - SetFormat(JSONFormat) - SetLevelOverride("tag", "manager", DebugLevel) - SetLevelOverride("tag", "publish", DebugLevel) - - Time(time.Unix(11, 0).UTC()).Field("tag", "manager").Debug("this is logged") - Time(time.Unix(12, 0).UTC()).Field("tag", "no-match").Debug("this is not logged") - Time(time.Unix(13, 0).UTC()).Field("tag", "publish").Info("this is also logged") - - expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","tag":"manager"} -{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","tag":"publish"} -` - require.Equal(t, expected, out.String()) - require.False(t, IsFile()) - require.Equal(t, "", File()) -} - -func TestLog_FieldIf(t *testing.T) { - t.Cleanup(resetState) - - var out bytes.Buffer - SetOutput(&out) - SetLevel(DebugLevel) - SetFormat(JSONFormat) - - Time(time.Unix(11, 0).UTC()). - FieldIf("trace_field", "manager", TraceLevel). // This is not logged - Field("tag", "manager"). - Debug("trace_field is not logged") - SetLevel(TraceLevel) - Time(time.Unix(12, 0).UTC()). - FieldIf("trace_field", "manager", TraceLevel). // Now it is logged - Field("tag", "manager"). - Debug("trace_field is logged") - - expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"trace_field is not logged","tag":"manager"} -{"time":"1970-01-01T00:00:12Z","level":"DEBUG","message":"trace_field is logged","tag":"manager","trace_field":"manager"} -` - require.Equal(t, expected, out.String()) -} - -func TestLog_UsingStdLogger_JSON(t *testing.T) { - t.Cleanup(resetState) - - var out bytes.Buffer - SetOutput(&out) - SetFormat(JSONFormat) - - log.Println("Some other library is using the standard Go logger") - require.Contains(t, out.String(), `,"level":"INFO","message":"Some other library is using the standard Go logger","tag":"stdlog"}`+"\n") -} - -func TestLog_UsingStdLogger_Text(t *testing.T) { - t.Cleanup(resetState) - - var out bytes.Buffer - SetOutput(&out) - - log.Println("Some other library is using the standard Go logger") - require.Contains(t, out.String(), `Some other library is using the standard Go logger`+"\n") - require.NotContains(t, out.String(), `{`) -} - -func TestLog_File(t *testing.T) { - t.Cleanup(resetState) - - logfile := filepath.Join(t.TempDir(), "ntfy.log") - f, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY, 0600) - require.Nil(t, err) - SetOutput(f) - SetFormat(JSONFormat) - require.True(t, IsFile()) - require.Equal(t, logfile, File()) - - Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Info("this is logged") - require.Nil(t, f.Close()) - - f, err = os.Open(logfile) - require.Nil(t, err) - contents, err := io.ReadAll(f) - require.Nil(t, err) - require.Equal(t, `{"time":"1970-01-01T00:00:11Z","level":"INFO","message":"this is logged","this_one":"11"}`+"\n", string(contents)) } type fakeError struct { diff --git a/log/types.go b/log/types.go index fd67637..dc1d2f3 100644 --- a/log/types.go +++ b/log/types.go @@ -102,13 +102,6 @@ type Contexter interface { // Context represents an object's state in the form of key-value pairs type Context map[string]any -// Merge merges other into this context -func (c Context) Merge(other Context) { - for k, v := range other { - c[k] = v - } -} - type levelOverride struct { value string level Level diff --git a/mkdocs.yml b/mkdocs.yml index 4a7db36..b937f95 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,11 +9,9 @@ edit_uri: blob/main/docs/ theme: name: material - font: false language: en - custom_dir: docs/_overrides logo: static/img/ntfy.png - favicon: static/img/favicon.ico + favicon: static/img/favicon.png include_search_page: false search_index_only: true palette: @@ -71,9 +69,6 @@ plugins: - search - minify: minify_html: true - - mkdocs-simple-hooks: - hooks: - on_post_build: "docs.hooks:copy_fonts" nav: - "Getting started": index.md @@ -93,7 +88,6 @@ nav: - "Integrations + projects": integrations.md - "Release notes": releases.md - "Emojis 🥳 🎉": emojis.md - - "Troubleshooting": troubleshooting.md - "Known issues": known-issues.md - "Deprecation notices": deprecations.md - "Development": develop.md diff --git a/requirements.txt b/requirements.txt index 17b0fc1..9c2212a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ # The documentation uses 'mkdocs', which is written in Python mkdocs-material mkdocs-minify-plugin -mkdocs-simple-hooks diff --git a/scripts/emoji-convert.sh b/scripts/emoji-convert.sh index 61ad5f7..9817c88 100755 --- a/scripts/emoji-convert.sh +++ b/scripts/emoji-convert.sh @@ -29,7 +29,7 @@ You can [tag messages](../publish/#tags-emojis) with emojis 🥳 🎉 and other converted to emojis. This is a reference of all supported emojis. To learn more about the feature, please refer to the [tagging and emojis page](../publish/#tags-emojis). - +
" > "$1" count="$(cat "$SCRIPTDIR/emoji.json" | jq -r '.[] | .emoji' | wc -l)" @@ -37,9 +37,9 @@ converted to emojis. This is a reference of all supported emojis. To learn more for col in 0 1 2; do from="$(($col * $percolumn + 1))" to="$(($col * $percolumn + 1 + $percolumn))" - echo "
" >> "$1" + echo "" >> "$1" done diff --git a/server/config.go b/server/config.go index a876926..04ac00b 100644 --- a/server/config.go +++ b/server/config.go @@ -49,7 +49,7 @@ const ( DefaultVisitorEmailLimitReplenish = time.Hour DefaultVisitorAccountCreationLimitBurst = 3 DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour - DefaultVisitorAuthFailureLimitBurst = 30 + DefaultVisitorAuthFailureLimitBurst = 10 DefaultVisitorAuthFailureLimitReplenish = time.Minute DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB @@ -61,7 +61,7 @@ var ( // DefaultDisallowedTopics defines the topics that are forbidden, because they are used elsewhere. This array can be // extended using the server.yml config. If updated, also update in Android and web app. - DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "metrics", "account", "settings", "signup", "login", "v1"} + DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"} ) // Config is the main config struct for the application. Use New to instantiate a default config struct. @@ -92,13 +92,12 @@ type Config struct { KeepaliveInterval time.Duration ManagerInterval time.Duration DisallowedTopics []string - WebRoot string // empty to disable + WebRootIsApp bool DelayedSenderInterval time.Duration FirebaseKeepaliveInterval time.Duration FirebasePollInterval time.Duration FirebaseQuotaExceededPenaltyDuration time.Duration UpstreamBaseURL string - UpstreamAccessToken string SMTPSenderAddr string SMTPSenderUser string SMTPSenderPass string @@ -106,15 +105,6 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string - TwilioAccount string - TwilioAuthToken string - TwilioPhoneNumber string - TwilioCallsBaseURL string - TwilioVerifyBaseURL string - TwilioVerifyService string - MetricsEnable bool - MetricsListenHTTP string - ProfileListenHTTP string MessageLimit int MinDelay time.Duration MaxDelay time.Duration @@ -134,16 +124,14 @@ type Config struct { VisitorAuthFailureLimitBurst int VisitorAuthFailureLimitReplenish time.Duration VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats - VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics BehindProxy bool StripeSecretKey string StripeWebhookKey string StripePriceCacheDuration time.Duration - BillingContact string + EnableWeb bool EnableSignup bool // Enable creation of accounts via API and UI EnableLogin bool - EnableReservations bool // Allow users with role "user" to own/reserve topics - EnableMetrics bool + EnableReservations bool // Allow users with role "user" to own/reserve topics AccessControlAllowOrigin string // CORS header field to restrict access from web clients Version string // injected by App } @@ -177,13 +165,12 @@ func NewConfig() *Config { KeepaliveInterval: DefaultKeepaliveInterval, ManagerInterval: DefaultManagerInterval, DisallowedTopics: DefaultDisallowedTopics, - WebRoot: "/", + WebRootIsApp: false, DelayedSenderInterval: DefaultDelayedSenderInterval, FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, FirebasePollInterval: DefaultFirebasePollInterval, FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration, UpstreamBaseURL: "", - UpstreamAccessToken: "", SMTPSenderAddr: "", SMTPSenderUser: "", SMTPSenderPass: "", @@ -191,12 +178,6 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", - TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests - TwilioAccount: "", - TwilioAuthToken: "", - TwilioPhoneNumber: "", - TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests - TwilioVerifyService: "", MessageLimit: DefaultMessageLengthLimit, MinDelay: DefaultMinDelay, MaxDelay: DefaultMaxDelay, @@ -216,12 +197,11 @@ func NewConfig() *Config { VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst, VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish, VisitorStatsResetTime: DefaultVisitorStatsResetTime, - VisitorSubscriberRateLimiting: false, BehindProxy: false, StripeSecretKey: "", StripeWebhookKey: "", StripePriceCacheDuration: DefaultStripePriceCacheDuration, - BillingContact: "", + EnableWeb: true, EnableSignup: false, EnableLogin: false, EnableReservations: false, diff --git a/server/errors.go b/server/errors.go index eee916b..a00105c 100644 --- a/server/errors.go +++ b/server/errors.go @@ -13,7 +13,6 @@ type errHTTP struct { HTTPCode int `json:"http"` Message string `json:"error"` Link string `json:"link,omitempty"` - context log.Context } func (e errHTTP) Error() string { @@ -26,117 +25,71 @@ func (e errHTTP) JSON() string { } func (e errHTTP) Context() log.Context { - context := log.Context{ + return log.Context{ "error": e.Message, "error_code": e.Code, "http_status": e.HTTPCode, } - for k, v := range e.context { - context[k] = v - } - return context } -func (e errHTTP) Wrap(message string, args ...any) *errHTTP { - clone := e.clone() - clone.Message = fmt.Sprintf("%s; %s", clone.Message, fmt.Sprintf(message, args...)) - return &clone -} - -func (e errHTTP) With(contexters ...log.Contexter) *errHTTP { - c := e.clone() - if c.context == nil { - c.context = make(log.Context) - } - for _, contexter := range contexters { - c.context.Merge(contexter.Context()) - } - return &c -} - -func (e errHTTP) Fields(context log.Context) *errHTTP { - c := e.clone() - if c.context == nil { - c.context = make(log.Context) - } - c.context.Merge(context) - return &c -} - -func (e errHTTP) clone() errHTTP { - context := make(log.Context) - for k, v := range e.context { - context[k] = v - } - return errHTTP{ - Code: e.Code, - HTTPCode: e.HTTPCode, - Message: e.Message, - Link: e.Link, - context: context, +func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP { + return &errHTTP{ + Code: err.Code, + HTTPCode: err.HTTPCode, + Message: fmt.Sprintf("%s, %s", err.Message, fmt.Sprintf(message, args...)), + Link: err.Link, } } var ( - errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil} - errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil} - errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", "", nil} - errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", "", nil} - errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} - errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} - errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} - errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority", nil} - errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil} - errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil} - errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil} - errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil} - errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil} - errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil} - errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} - errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets", nil} - errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json", nil} - errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons", nil} - errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway", nil} - errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons", nil} - errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config", nil} - errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", "", nil} - errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", "", nil} - errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", "", nil} - errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", "", nil} - errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil} - errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil} - errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil} - errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil} - errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil} - errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil} - errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil} - errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil} - errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil} - errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil} - errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil} - errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} - errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} - errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} - errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil} - errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil} - errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil} - errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil} - errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil} - errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} - errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil} - errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit - errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil} - errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit - errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil} - errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil} - errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil} - errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil} + errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", ""} + errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} + errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} + errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} + errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} + errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} + errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", ""} + errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", ""} + errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} + errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"} + errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"} + errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"} + errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"} + errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"} + errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"} + errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"} + errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"} + errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config"} + errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""} + errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""} + errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""} + errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", ""} + errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""} + errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""} + errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""} + errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} + errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} + errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} + errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""} + errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""} + errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", ""} + errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""} + errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""} + errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit + errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""} + errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit + errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} + errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""} + errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"} ) diff --git a/server/file_cache.go b/server/file_cache.go index c097aef..35cb0f4 100644 --- a/server/file_cache.go +++ b/server/file_cache.go @@ -67,7 +67,6 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in } c.mu.Lock() c.totalSizeCurrent += size - mset(metricAttachmentsTotalSize, c.totalSizeCurrent) c.mu.Unlock() return size, nil } @@ -90,7 +89,6 @@ func (c *fileCache) Remove(ids ...string) error { c.mu.Lock() c.totalSizeCurrent = size c.mu.Unlock() - mset(metricAttachmentsTotalSize, size) return nil } diff --git a/server/log.go b/server/log.go index c638ed9..d385821 100644 --- a/server/log.go +++ b/server/log.go @@ -20,7 +20,6 @@ const ( tagFirebase = "firebase" tagSMTP = "smtp" // Receive email tagEmail = "email" // Send email - tagTwilio = "twilio" tagFileCache = "file_cache" tagMessageCache = "message_cache" tagStripe = "stripe" @@ -31,11 +30,6 @@ const ( tagMatrix = "matrix" ) -var ( - normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage} - rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge} -) - // logr creates a new log event with HTTP request fields func logr(r *http.Request) *log.Event { return log.Tag(tagHTTP).Fields(httpContext(r)) // Tag may be overwritten diff --git a/server/mailer_emoji.json b/server/mailer_emoji.json new file mode 100644 index 0000000..4d4c32f --- /dev/null +++ b/server/mailer_emoji.json @@ -0,0 +1 @@ +[{"emoji":"😀","aliases":["grinning"]},{"emoji":"😃","aliases":["smiley"]},{"emoji":"😄","aliases":["smile"]},{"emoji":"😁","aliases":["grin"]},{"emoji":"😆","aliases":["laughing","satisfied"]},{"emoji":"😅","aliases":["sweat_smile"]},{"emoji":"🤣","aliases":["rofl"]},{"emoji":"😂","aliases":["joy"]},{"emoji":"🙂","aliases":["slightly_smiling_face"]},{"emoji":"🙃","aliases":["upside_down_face"]},{"emoji":"😉","aliases":["wink"]},{"emoji":"😊","aliases":["blush"]},{"emoji":"😇","aliases":["innocent"]},{"emoji":"🥰","aliases":["smiling_face_with_three_hearts"]},{"emoji":"😍","aliases":["heart_eyes"]},{"emoji":"🤩","aliases":["star_struck"]},{"emoji":"😘","aliases":["kissing_heart"]},{"emoji":"😗","aliases":["kissing"]},{"emoji":"☺️","aliases":["relaxed"]},{"emoji":"😚","aliases":["kissing_closed_eyes"]},{"emoji":"😙","aliases":["kissing_smiling_eyes"]},{"emoji":"🥲","aliases":["smiling_face_with_tear"]},{"emoji":"😋","aliases":["yum"]},{"emoji":"😛","aliases":["stuck_out_tongue"]},{"emoji":"😜","aliases":["stuck_out_tongue_winking_eye"]},{"emoji":"🤪","aliases":["zany_face"]},{"emoji":"😝","aliases":["stuck_out_tongue_closed_eyes"]},{"emoji":"🤑","aliases":["money_mouth_face"]},{"emoji":"🤗","aliases":["hugs"]},{"emoji":"🤭","aliases":["hand_over_mouth"]},{"emoji":"🤫","aliases":["shushing_face"]},{"emoji":"🤔","aliases":["thinking"]},{"emoji":"🤐","aliases":["zipper_mouth_face"]},{"emoji":"🤨","aliases":["raised_eyebrow"]},{"emoji":"😐","aliases":["neutral_face"]},{"emoji":"😑","aliases":["expressionless"]},{"emoji":"😶","aliases":["no_mouth"]},{"emoji":"😶‍🌫️","aliases":["face_in_clouds"]},{"emoji":"😏","aliases":["smirk"]},{"emoji":"😒","aliases":["unamused"]},{"emoji":"🙄","aliases":["roll_eyes"]},{"emoji":"😬","aliases":["grimacing"]},{"emoji":"😮‍💨","aliases":["face_exhaling"]},{"emoji":"🤥","aliases":["lying_face"]},{"emoji":"😌","aliases":["relieved"]},{"emoji":"😔","aliases":["pensive"]},{"emoji":"😪","aliases":["sleepy"]},{"emoji":"🤤","aliases":["drooling_face"]},{"emoji":"😴","aliases":["sleeping"]},{"emoji":"😷","aliases":["mask"]},{"emoji":"🤒","aliases":["face_with_thermometer"]},{"emoji":"🤕","aliases":["face_with_head_bandage"]},{"emoji":"🤢","aliases":["nauseated_face"]},{"emoji":"🤮","aliases":["vomiting_face"]},{"emoji":"🤧","aliases":["sneezing_face"]},{"emoji":"🥵","aliases":["hot_face"]},{"emoji":"🥶","aliases":["cold_face"]},{"emoji":"🥴","aliases":["woozy_face"]},{"emoji":"😵","aliases":["dizzy_face"]},{"emoji":"😵‍💫","aliases":["face_with_spiral_eyes"]},{"emoji":"🤯","aliases":["exploding_head"]},{"emoji":"🤠","aliases":["cowboy_hat_face"]},{"emoji":"🥳","aliases":["partying_face"]},{"emoji":"🥸","aliases":["disguised_face"]},{"emoji":"😎","aliases":["sunglasses"]},{"emoji":"🤓","aliases":["nerd_face"]},{"emoji":"🧐","aliases":["monocle_face"]},{"emoji":"😕","aliases":["confused"]},{"emoji":"😟","aliases":["worried"]},{"emoji":"🙁","aliases":["slightly_frowning_face"]},{"emoji":"☹️","aliases":["frowning_face"]},{"emoji":"😮","aliases":["open_mouth"]},{"emoji":"😯","aliases":["hushed"]},{"emoji":"😲","aliases":["astonished"]},{"emoji":"😳","aliases":["flushed"]},{"emoji":"🥺","aliases":["pleading_face"]},{"emoji":"😦","aliases":["frowning"]},{"emoji":"😧","aliases":["anguished"]},{"emoji":"😨","aliases":["fearful"]},{"emoji":"😰","aliases":["cold_sweat"]},{"emoji":"😥","aliases":["disappointed_relieved"]},{"emoji":"😢","aliases":["cry"]},{"emoji":"😭","aliases":["sob"]},{"emoji":"😱","aliases":["scream"]},{"emoji":"😖","aliases":["confounded"]},{"emoji":"😣","aliases":["persevere"]},{"emoji":"😞","aliases":["disappointed"]},{"emoji":"😓","aliases":["sweat"]},{"emoji":"😩","aliases":["weary"]},{"emoji":"😫","aliases":["tired_face"]},{"emoji":"🥱","aliases":["yawning_face"]},{"emoji":"😤","aliases":["triumph"]},{"emoji":"😡","aliases":["rage","pout"]},{"emoji":"😠","aliases":["angry"]},{"emoji":"🤬","aliases":["cursing_face"]},{"emoji":"😈","aliases":["smiling_imp"]},{"emoji":"👿","aliases":["imp"]},{"emoji":"💀","aliases":["skull"]},{"emoji":"☠️","aliases":["skull_and_crossbones"]},{"emoji":"💩","aliases":["hankey","poop","shit"]},{"emoji":"🤡","aliases":["clown_face"]},{"emoji":"👹","aliases":["japanese_ogre"]},{"emoji":"👺","aliases":["japanese_goblin"]},{"emoji":"👻","aliases":["ghost"]},{"emoji":"👽","aliases":["alien"]},{"emoji":"👾","aliases":["space_invader"]},{"emoji":"🤖","aliases":["robot"]},{"emoji":"😺","aliases":["smiley_cat"]},{"emoji":"😸","aliases":["smile_cat"]},{"emoji":"😹","aliases":["joy_cat"]},{"emoji":"😻","aliases":["heart_eyes_cat"]},{"emoji":"😼","aliases":["smirk_cat"]},{"emoji":"😽","aliases":["kissing_cat"]},{"emoji":"🙀","aliases":["scream_cat"]},{"emoji":"😿","aliases":["crying_cat_face"]},{"emoji":"😾","aliases":["pouting_cat"]},{"emoji":"🙈","aliases":["see_no_evil"]},{"emoji":"🙉","aliases":["hear_no_evil"]},{"emoji":"🙊","aliases":["speak_no_evil"]},{"emoji":"💋","aliases":["kiss"]},{"emoji":"💌","aliases":["love_letter"]},{"emoji":"💘","aliases":["cupid"]},{"emoji":"💝","aliases":["gift_heart"]},{"emoji":"💖","aliases":["sparkling_heart"]},{"emoji":"💗","aliases":["heartpulse"]},{"emoji":"💓","aliases":["heartbeat"]},{"emoji":"💞","aliases":["revolving_hearts"]},{"emoji":"💕","aliases":["two_hearts"]},{"emoji":"💟","aliases":["heart_decoration"]},{"emoji":"❣️","aliases":["heavy_heart_exclamation"]},{"emoji":"💔","aliases":["broken_heart"]},{"emoji":"❤️‍🔥","aliases":["heart_on_fire"]},{"emoji":"❤️‍🩹","aliases":["mending_heart"]},{"emoji":"❤️","aliases":["heart"]},{"emoji":"🧡","aliases":["orange_heart"]},{"emoji":"💛","aliases":["yellow_heart"]},{"emoji":"💚","aliases":["green_heart"]},{"emoji":"💙","aliases":["blue_heart"]},{"emoji":"💜","aliases":["purple_heart"]},{"emoji":"🤎","aliases":["brown_heart"]},{"emoji":"🖤","aliases":["black_heart"]},{"emoji":"🤍","aliases":["white_heart"]},{"emoji":"💯","aliases":["100"]},{"emoji":"💢","aliases":["anger"]},{"emoji":"💥","aliases":["boom","collision"]},{"emoji":"💫","aliases":["dizzy"]},{"emoji":"💦","aliases":["sweat_drops"]},{"emoji":"💨","aliases":["dash"]},{"emoji":"🕳️","aliases":["hole"]},{"emoji":"💣","aliases":["bomb"]},{"emoji":"💬","aliases":["speech_balloon"]},{"emoji":"👁️‍🗨️","aliases":["eye_speech_bubble"]},{"emoji":"🗨️","aliases":["left_speech_bubble"]},{"emoji":"🗯️","aliases":["right_anger_bubble"]},{"emoji":"💭","aliases":["thought_balloon"]},{"emoji":"💤","aliases":["zzz"]},{"emoji":"👋","aliases":["wave"]},{"emoji":"🤚","aliases":["raised_back_of_hand"]},{"emoji":"🖐️","aliases":["raised_hand_with_fingers_splayed"]},{"emoji":"✋","aliases":["hand","raised_hand"]},{"emoji":"🖖","aliases":["vulcan_salute"]},{"emoji":"👌","aliases":["ok_hand"]},{"emoji":"🤌","aliases":["pinched_fingers"]},{"emoji":"🤏","aliases":["pinching_hand"]},{"emoji":"✌️","aliases":["v"]},{"emoji":"🤞","aliases":["crossed_fingers"]},{"emoji":"🤟","aliases":["love_you_gesture"]},{"emoji":"🤘","aliases":["metal"]},{"emoji":"🤙","aliases":["call_me_hand"]},{"emoji":"👈","aliases":["point_left"]},{"emoji":"👉","aliases":["point_right"]},{"emoji":"👆","aliases":["point_up_2"]},{"emoji":"🖕","aliases":["middle_finger","fu"]},{"emoji":"👇","aliases":["point_down"]},{"emoji":"☝️","aliases":["point_up"]},{"emoji":"👍","aliases":["+1","thumbsup"]},{"emoji":"👎","aliases":["-1","thumbsdown"]},{"emoji":"✊","aliases":["fist_raised","fist"]},{"emoji":"👊","aliases":["fist_oncoming","facepunch","punch"]},{"emoji":"🤛","aliases":["fist_left"]},{"emoji":"🤜","aliases":["fist_right"]},{"emoji":"👏","aliases":["clap"]},{"emoji":"🙌","aliases":["raised_hands"]},{"emoji":"👐","aliases":["open_hands"]},{"emoji":"🤲","aliases":["palms_up_together"]},{"emoji":"🤝","aliases":["handshake"]},{"emoji":"🙏","aliases":["pray"]},{"emoji":"✍️","aliases":["writing_hand"]},{"emoji":"💅","aliases":["nail_care"]},{"emoji":"🤳","aliases":["selfie"]},{"emoji":"💪","aliases":["muscle"]},{"emoji":"🦾","aliases":["mechanical_arm"]},{"emoji":"🦿","aliases":["mechanical_leg"]},{"emoji":"🦵","aliases":["leg"]},{"emoji":"🦶","aliases":["foot"]},{"emoji":"👂","aliases":["ear"]},{"emoji":"🦻","aliases":["ear_with_hearing_aid"]},{"emoji":"👃","aliases":["nose"]},{"emoji":"🧠","aliases":["brain"]},{"emoji":"🫀","aliases":["anatomical_heart"]},{"emoji":"🫁","aliases":["lungs"]},{"emoji":"🦷","aliases":["tooth"]},{"emoji":"🦴","aliases":["bone"]},{"emoji":"👀","aliases":["eyes"]},{"emoji":"👁️","aliases":["eye"]},{"emoji":"👅","aliases":["tongue"]},{"emoji":"👄","aliases":["lips"]},{"emoji":"👶","aliases":["baby"]},{"emoji":"🧒","aliases":["child"]},{"emoji":"👦","aliases":["boy"]},{"emoji":"👧","aliases":["girl"]},{"emoji":"🧑","aliases":["adult"]},{"emoji":"👱","aliases":["blond_haired_person"]},{"emoji":"👨","aliases":["man"]},{"emoji":"🧔","aliases":["bearded_person"]},{"emoji":"🧔‍♂️","aliases":["man_beard"]},{"emoji":"🧔‍♀️","aliases":["woman_beard"]},{"emoji":"👨‍🦰","aliases":["red_haired_man"]},{"emoji":"👨‍🦱","aliases":["curly_haired_man"]},{"emoji":"👨‍🦳","aliases":["white_haired_man"]},{"emoji":"👨‍🦲","aliases":["bald_man"]},{"emoji":"👩","aliases":["woman"]},{"emoji":"👩‍🦰","aliases":["red_haired_woman"]},{"emoji":"🧑‍🦰","aliases":["person_red_hair"]},{"emoji":"👩‍🦱","aliases":["curly_haired_woman"]},{"emoji":"🧑‍🦱","aliases":["person_curly_hair"]},{"emoji":"👩‍🦳","aliases":["white_haired_woman"]},{"emoji":"🧑‍🦳","aliases":["person_white_hair"]},{"emoji":"👩‍🦲","aliases":["bald_woman"]},{"emoji":"🧑‍🦲","aliases":["person_bald"]},{"emoji":"👱‍♀️","aliases":["blond_haired_woman","blonde_woman"]},{"emoji":"👱‍♂️","aliases":["blond_haired_man"]},{"emoji":"🧓","aliases":["older_adult"]},{"emoji":"👴","aliases":["older_man"]},{"emoji":"👵","aliases":["older_woman"]},{"emoji":"🙍","aliases":["frowning_person"]},{"emoji":"🙍‍♂️","aliases":["frowning_man"]},{"emoji":"🙍‍♀️","aliases":["frowning_woman"]},{"emoji":"🙎","aliases":["pouting_face"]},{"emoji":"🙎‍♂️","aliases":["pouting_man"]},{"emoji":"🙎‍♀️","aliases":["pouting_woman"]},{"emoji":"🙅","aliases":["no_good"]},{"emoji":"🙅‍♂️","aliases":["no_good_man","ng_man"]},{"emoji":"🙅‍♀️","aliases":["no_good_woman","ng_woman"]},{"emoji":"🙆","aliases":["ok_person"]},{"emoji":"🙆‍♂️","aliases":["ok_man"]},{"emoji":"🙆‍♀️","aliases":["ok_woman"]},{"emoji":"💁","aliases":["tipping_hand_person","information_desk_person"]},{"emoji":"💁‍♂️","aliases":["tipping_hand_man","sassy_man"]},{"emoji":"💁‍♀️","aliases":["tipping_hand_woman","sassy_woman"]},{"emoji":"🙋","aliases":["raising_hand"]},{"emoji":"🙋‍♂️","aliases":["raising_hand_man"]},{"emoji":"🙋‍♀️","aliases":["raising_hand_woman"]},{"emoji":"🧏","aliases":["deaf_person"]},{"emoji":"🧏‍♂️","aliases":["deaf_man"]},{"emoji":"🧏‍♀️","aliases":["deaf_woman"]},{"emoji":"🙇","aliases":["bow"]},{"emoji":"🙇‍♂️","aliases":["bowing_man"]},{"emoji":"🙇‍♀️","aliases":["bowing_woman"]},{"emoji":"🤦","aliases":["facepalm"]},{"emoji":"🤦‍♂️","aliases":["man_facepalming"]},{"emoji":"🤦‍♀️","aliases":["woman_facepalming"]},{"emoji":"🤷","aliases":["shrug"]},{"emoji":"🤷‍♂️","aliases":["man_shrugging"]},{"emoji":"🤷‍♀️","aliases":["woman_shrugging"]},{"emoji":"🧑‍⚕️","aliases":["health_worker"]},{"emoji":"👨‍⚕️","aliases":["man_health_worker"]},{"emoji":"👩‍⚕️","aliases":["woman_health_worker"]},{"emoji":"🧑‍🎓","aliases":["student"]},{"emoji":"👨‍🎓","aliases":["man_student"]},{"emoji":"👩‍🎓","aliases":["woman_student"]},{"emoji":"🧑‍🏫","aliases":["teacher"]},{"emoji":"👨‍🏫","aliases":["man_teacher"]},{"emoji":"👩‍🏫","aliases":["woman_teacher"]},{"emoji":"🧑‍⚖️","aliases":["judge"]},{"emoji":"👨‍⚖️","aliases":["man_judge"]},{"emoji":"👩‍⚖️","aliases":["woman_judge"]},{"emoji":"🧑‍🌾","aliases":["farmer"]},{"emoji":"👨‍🌾","aliases":["man_farmer"]},{"emoji":"👩‍🌾","aliases":["woman_farmer"]},{"emoji":"🧑‍🍳","aliases":["cook"]},{"emoji":"👨‍🍳","aliases":["man_cook"]},{"emoji":"👩‍🍳","aliases":["woman_cook"]},{"emoji":"🧑‍🔧","aliases":["mechanic"]},{"emoji":"👨‍🔧","aliases":["man_mechanic"]},{"emoji":"👩‍🔧","aliases":["woman_mechanic"]},{"emoji":"🧑‍🏭","aliases":["factory_worker"]},{"emoji":"👨‍🏭","aliases":["man_factory_worker"]},{"emoji":"👩‍🏭","aliases":["woman_factory_worker"]},{"emoji":"🧑‍💼","aliases":["office_worker"]},{"emoji":"👨‍💼","aliases":["man_office_worker"]},{"emoji":"👩‍💼","aliases":["woman_office_worker"]},{"emoji":"🧑‍🔬","aliases":["scientist"]},{"emoji":"👨‍🔬","aliases":["man_scientist"]},{"emoji":"👩‍🔬","aliases":["woman_scientist"]},{"emoji":"🧑‍💻","aliases":["technologist"]},{"emoji":"👨‍💻","aliases":["man_technologist"]},{"emoji":"👩‍💻","aliases":["woman_technologist"]},{"emoji":"🧑‍🎤","aliases":["singer"]},{"emoji":"👨‍🎤","aliases":["man_singer"]},{"emoji":"👩‍🎤","aliases":["woman_singer"]},{"emoji":"🧑‍🎨","aliases":["artist"]},{"emoji":"👨‍🎨","aliases":["man_artist"]},{"emoji":"👩‍🎨","aliases":["woman_artist"]},{"emoji":"🧑‍✈️","aliases":["pilot"]},{"emoji":"👨‍✈️","aliases":["man_pilot"]},{"emoji":"👩‍✈️","aliases":["woman_pilot"]},{"emoji":"🧑‍🚀","aliases":["astronaut"]},{"emoji":"👨‍🚀","aliases":["man_astronaut"]},{"emoji":"👩‍🚀","aliases":["woman_astronaut"]},{"emoji":"🧑‍🚒","aliases":["firefighter"]},{"emoji":"👨‍🚒","aliases":["man_firefighter"]},{"emoji":"👩‍🚒","aliases":["woman_firefighter"]},{"emoji":"👮","aliases":["police_officer","cop"]},{"emoji":"👮‍♂️","aliases":["policeman"]},{"emoji":"👮‍♀️","aliases":["policewoman"]},{"emoji":"🕵️","aliases":["detective"]},{"emoji":"🕵️‍♂️","aliases":["male_detective"]},{"emoji":"🕵️‍♀️","aliases":["female_detective"]},{"emoji":"💂","aliases":["guard"]},{"emoji":"💂‍♂️","aliases":["guardsman"]},{"emoji":"💂‍♀️","aliases":["guardswoman"]},{"emoji":"🥷","aliases":["ninja"]},{"emoji":"👷","aliases":["construction_worker"]},{"emoji":"👷‍♂️","aliases":["construction_worker_man"]},{"emoji":"👷‍♀️","aliases":["construction_worker_woman"]},{"emoji":"🤴","aliases":["prince"]},{"emoji":"👸","aliases":["princess"]},{"emoji":"👳","aliases":["person_with_turban"]},{"emoji":"👳‍♂️","aliases":["man_with_turban"]},{"emoji":"👳‍♀️","aliases":["woman_with_turban"]},{"emoji":"👲","aliases":["man_with_gua_pi_mao"]},{"emoji":"🧕","aliases":["woman_with_headscarf"]},{"emoji":"🤵","aliases":["person_in_tuxedo"]},{"emoji":"🤵‍♂️","aliases":["man_in_tuxedo"]},{"emoji":"🤵‍♀️","aliases":["woman_in_tuxedo"]},{"emoji":"👰","aliases":["person_with_veil"]},{"emoji":"👰‍♂️","aliases":["man_with_veil"]},{"emoji":"👰‍♀️","aliases":["woman_with_veil","bride_with_veil"]},{"emoji":"🤰","aliases":["pregnant_woman"]},{"emoji":"🤱","aliases":["breast_feeding"]},{"emoji":"👩‍🍼","aliases":["woman_feeding_baby"]},{"emoji":"👨‍🍼","aliases":["man_feeding_baby"]},{"emoji":"🧑‍🍼","aliases":["person_feeding_baby"]},{"emoji":"👼","aliases":["angel"]},{"emoji":"🎅","aliases":["santa"]},{"emoji":"🤶","aliases":["mrs_claus"]},{"emoji":"🧑‍🎄","aliases":["mx_claus"]},{"emoji":"🦸","aliases":["superhero"]},{"emoji":"🦸‍♂️","aliases":["superhero_man"]},{"emoji":"🦸‍♀️","aliases":["superhero_woman"]},{"emoji":"🦹","aliases":["supervillain"]},{"emoji":"🦹‍♂️","aliases":["supervillain_man"]},{"emoji":"🦹‍♀️","aliases":["supervillain_woman"]},{"emoji":"🧙","aliases":["mage"]},{"emoji":"🧙‍♂️","aliases":["mage_man"]},{"emoji":"🧙‍♀️","aliases":["mage_woman"]},{"emoji":"🧚","aliases":["fairy"]},{"emoji":"🧚‍♂️","aliases":["fairy_man"]},{"emoji":"🧚‍♀️","aliases":["fairy_woman"]},{"emoji":"🧛","aliases":["vampire"]},{"emoji":"🧛‍♂️","aliases":["vampire_man"]},{"emoji":"🧛‍♀️","aliases":["vampire_woman"]},{"emoji":"🧜","aliases":["merperson"]},{"emoji":"🧜‍♂️","aliases":["merman"]},{"emoji":"🧜‍♀️","aliases":["mermaid"]},{"emoji":"🧝","aliases":["elf"]},{"emoji":"🧝‍♂️","aliases":["elf_man"]},{"emoji":"🧝‍♀️","aliases":["elf_woman"]},{"emoji":"🧞","aliases":["genie"]},{"emoji":"🧞‍♂️","aliases":["genie_man"]},{"emoji":"🧞‍♀️","aliases":["genie_woman"]},{"emoji":"🧟","aliases":["zombie"]},{"emoji":"🧟‍♂️","aliases":["zombie_man"]},{"emoji":"🧟‍♀️","aliases":["zombie_woman"]},{"emoji":"💆","aliases":["massage"]},{"emoji":"💆‍♂️","aliases":["massage_man"]},{"emoji":"💆‍♀️","aliases":["massage_woman"]},{"emoji":"💇","aliases":["haircut"]},{"emoji":"💇‍♂️","aliases":["haircut_man"]},{"emoji":"💇‍♀️","aliases":["haircut_woman"]},{"emoji":"🚶","aliases":["walking"]},{"emoji":"🚶‍♂️","aliases":["walking_man"]},{"emoji":"🚶‍♀️","aliases":["walking_woman"]},{"emoji":"🧍","aliases":["standing_person"]},{"emoji":"🧍‍♂️","aliases":["standing_man"]},{"emoji":"🧍‍♀️","aliases":["standing_woman"]},{"emoji":"🧎","aliases":["kneeling_person"]},{"emoji":"🧎‍♂️","aliases":["kneeling_man"]},{"emoji":"🧎‍♀️","aliases":["kneeling_woman"]},{"emoji":"🧑‍🦯","aliases":["person_with_probing_cane"]},{"emoji":"👨‍🦯","aliases":["man_with_probing_cane"]},{"emoji":"👩‍🦯","aliases":["woman_with_probing_cane"]},{"emoji":"🧑‍🦼","aliases":["person_in_motorized_wheelchair"]},{"emoji":"👨‍🦼","aliases":["man_in_motorized_wheelchair"]},{"emoji":"👩‍🦼","aliases":["woman_in_motorized_wheelchair"]},{"emoji":"🧑‍🦽","aliases":["person_in_manual_wheelchair"]},{"emoji":"👨‍🦽","aliases":["man_in_manual_wheelchair"]},{"emoji":"👩‍🦽","aliases":["woman_in_manual_wheelchair"]},{"emoji":"🏃","aliases":["runner","running"]},{"emoji":"🏃‍♂️","aliases":["running_man"]},{"emoji":"🏃‍♀️","aliases":["running_woman"]},{"emoji":"💃","aliases":["woman_dancing","dancer"]},{"emoji":"🕺","aliases":["man_dancing"]},{"emoji":"🕴️","aliases":["business_suit_levitating"]},{"emoji":"👯","aliases":["dancers"]},{"emoji":"👯‍♂️","aliases":["dancing_men"]},{"emoji":"👯‍♀️","aliases":["dancing_women"]},{"emoji":"🧖","aliases":["sauna_person"]},{"emoji":"🧖‍♂️","aliases":["sauna_man"]},{"emoji":"🧖‍♀️","aliases":["sauna_woman"]},{"emoji":"🧗","aliases":["climbing"]},{"emoji":"🧗‍♂️","aliases":["climbing_man"]},{"emoji":"🧗‍♀️","aliases":["climbing_woman"]},{"emoji":"🤺","aliases":["person_fencing"]},{"emoji":"🏇","aliases":["horse_racing"]},{"emoji":"⛷️","aliases":["skier"]},{"emoji":"🏂","aliases":["snowboarder"]},{"emoji":"🏌️","aliases":["golfing"]},{"emoji":"🏌️‍♂️","aliases":["golfing_man"]},{"emoji":"🏌️‍♀️","aliases":["golfing_woman"]},{"emoji":"🏄","aliases":["surfer"]},{"emoji":"🏄‍♂️","aliases":["surfing_man"]},{"emoji":"🏄‍♀️","aliases":["surfing_woman"]},{"emoji":"🚣","aliases":["rowboat"]},{"emoji":"🚣‍♂️","aliases":["rowing_man"]},{"emoji":"🚣‍♀️","aliases":["rowing_woman"]},{"emoji":"🏊","aliases":["swimmer"]},{"emoji":"🏊‍♂️","aliases":["swimming_man"]},{"emoji":"🏊‍♀️","aliases":["swimming_woman"]},{"emoji":"⛹️","aliases":["bouncing_ball_person"]},{"emoji":"⛹️‍♂️","aliases":["bouncing_ball_man","basketball_man"]},{"emoji":"⛹️‍♀️","aliases":["bouncing_ball_woman","basketball_woman"]},{"emoji":"🏋️","aliases":["weight_lifting"]},{"emoji":"🏋️‍♂️","aliases":["weight_lifting_man"]},{"emoji":"🏋️‍♀️","aliases":["weight_lifting_woman"]},{"emoji":"🚴","aliases":["bicyclist"]},{"emoji":"🚴‍♂️","aliases":["biking_man"]},{"emoji":"🚴‍♀️","aliases":["biking_woman"]},{"emoji":"🚵","aliases":["mountain_bicyclist"]},{"emoji":"🚵‍♂️","aliases":["mountain_biking_man"]},{"emoji":"🚵‍♀️","aliases":["mountain_biking_woman"]},{"emoji":"🤸","aliases":["cartwheeling"]},{"emoji":"🤸‍♂️","aliases":["man_cartwheeling"]},{"emoji":"🤸‍♀️","aliases":["woman_cartwheeling"]},{"emoji":"🤼","aliases":["wrestling"]},{"emoji":"🤼‍♂️","aliases":["men_wrestling"]},{"emoji":"🤼‍♀️","aliases":["women_wrestling"]},{"emoji":"🤽","aliases":["water_polo"]},{"emoji":"🤽‍♂️","aliases":["man_playing_water_polo"]},{"emoji":"🤽‍♀️","aliases":["woman_playing_water_polo"]},{"emoji":"🤾","aliases":["handball_person"]},{"emoji":"🤾‍♂️","aliases":["man_playing_handball"]},{"emoji":"🤾‍♀️","aliases":["woman_playing_handball"]},{"emoji":"🤹","aliases":["juggling_person"]},{"emoji":"🤹‍♂️","aliases":["man_juggling"]},{"emoji":"🤹‍♀️","aliases":["woman_juggling"]},{"emoji":"🧘","aliases":["lotus_position"]},{"emoji":"🧘‍♂️","aliases":["lotus_position_man"]},{"emoji":"🧘‍♀️","aliases":["lotus_position_woman"]},{"emoji":"🛀","aliases":["bath"]},{"emoji":"🛌","aliases":["sleeping_bed"]},{"emoji":"🧑‍🤝‍🧑","aliases":["people_holding_hands"]},{"emoji":"👭","aliases":["two_women_holding_hands"]},{"emoji":"👫","aliases":["couple"]},{"emoji":"👬","aliases":["two_men_holding_hands"]},{"emoji":"💏","aliases":["couplekiss"]},{"emoji":"👩‍❤️‍💋‍👨","aliases":["couplekiss_man_woman"]},{"emoji":"👨‍❤️‍💋‍👨","aliases":["couplekiss_man_man"]},{"emoji":"👩‍❤️‍💋‍👩","aliases":["couplekiss_woman_woman"]},{"emoji":"💑","aliases":["couple_with_heart"]},{"emoji":"👩‍❤️‍👨","aliases":["couple_with_heart_woman_man"]},{"emoji":"👨‍❤️‍👨","aliases":["couple_with_heart_man_man"]},{"emoji":"👩‍❤️‍👩","aliases":["couple_with_heart_woman_woman"]},{"emoji":"👪","aliases":["family"]},{"emoji":"👨‍👩‍👦","aliases":["family_man_woman_boy"]},{"emoji":"👨‍👩‍👧","aliases":["family_man_woman_girl"]},{"emoji":"👨‍👩‍👧‍👦","aliases":["family_man_woman_girl_boy"]},{"emoji":"👨‍👩‍👦‍👦","aliases":["family_man_woman_boy_boy"]},{"emoji":"👨‍👩‍👧‍👧","aliases":["family_man_woman_girl_girl"]},{"emoji":"👨‍👨‍👦","aliases":["family_man_man_boy"]},{"emoji":"👨‍👨‍👧","aliases":["family_man_man_girl"]},{"emoji":"👨‍👨‍👧‍👦","aliases":["family_man_man_girl_boy"]},{"emoji":"👨‍👨‍👦‍👦","aliases":["family_man_man_boy_boy"]},{"emoji":"👨‍👨‍👧‍👧","aliases":["family_man_man_girl_girl"]},{"emoji":"👩‍👩‍👦","aliases":["family_woman_woman_boy"]},{"emoji":"👩‍👩‍👧","aliases":["family_woman_woman_girl"]},{"emoji":"👩‍👩‍👧‍👦","aliases":["family_woman_woman_girl_boy"]},{"emoji":"👩‍👩‍👦‍👦","aliases":["family_woman_woman_boy_boy"]},{"emoji":"👩‍👩‍👧‍👧","aliases":["family_woman_woman_girl_girl"]},{"emoji":"👨‍👦","aliases":["family_man_boy"]},{"emoji":"👨‍👦‍👦","aliases":["family_man_boy_boy"]},{"emoji":"👨‍👧","aliases":["family_man_girl"]},{"emoji":"👨‍👧‍👦","aliases":["family_man_girl_boy"]},{"emoji":"👨‍👧‍👧","aliases":["family_man_girl_girl"]},{"emoji":"👩‍👦","aliases":["family_woman_boy"]},{"emoji":"👩‍👦‍👦","aliases":["family_woman_boy_boy"]},{"emoji":"👩‍👧","aliases":["family_woman_girl"]},{"emoji":"👩‍👧‍👦","aliases":["family_woman_girl_boy"]},{"emoji":"👩‍👧‍👧","aliases":["family_woman_girl_girl"]},{"emoji":"🗣️","aliases":["speaking_head"]},{"emoji":"👤","aliases":["bust_in_silhouette"]},{"emoji":"👥","aliases":["busts_in_silhouette"]},{"emoji":"🫂","aliases":["people_hugging"]},{"emoji":"👣","aliases":["footprints"]},{"emoji":"🐵","aliases":["monkey_face"]},{"emoji":"🐒","aliases":["monkey"]},{"emoji":"🦍","aliases":["gorilla"]},{"emoji":"🦧","aliases":["orangutan"]},{"emoji":"🐶","aliases":["dog"]},{"emoji":"🐕","aliases":["dog2"]},{"emoji":"🦮","aliases":["guide_dog"]},{"emoji":"🐕‍🦺","aliases":["service_dog"]},{"emoji":"🐩","aliases":["poodle"]},{"emoji":"🐺","aliases":["wolf"]},{"emoji":"🦊","aliases":["fox_face"]},{"emoji":"🦝","aliases":["raccoon"]},{"emoji":"🐱","aliases":["cat"]},{"emoji":"🐈","aliases":["cat2"]},{"emoji":"🐈‍⬛","aliases":["black_cat"]},{"emoji":"🦁","aliases":["lion"]},{"emoji":"🐯","aliases":["tiger"]},{"emoji":"🐅","aliases":["tiger2"]},{"emoji":"🐆","aliases":["leopard"]},{"emoji":"🐴","aliases":["horse"]},{"emoji":"🐎","aliases":["racehorse"]},{"emoji":"🦄","aliases":["unicorn"]},{"emoji":"🦓","aliases":["zebra"]},{"emoji":"🦌","aliases":["deer"]},{"emoji":"🦬","aliases":["bison"]},{"emoji":"🐮","aliases":["cow"]},{"emoji":"🐂","aliases":["ox"]},{"emoji":"🐃","aliases":["water_buffalo"]},{"emoji":"🐄","aliases":["cow2"]},{"emoji":"🐷","aliases":["pig"]},{"emoji":"🐖","aliases":["pig2"]},{"emoji":"🐗","aliases":["boar"]},{"emoji":"🐽","aliases":["pig_nose"]},{"emoji":"🐏","aliases":["ram"]},{"emoji":"🐑","aliases":["sheep"]},{"emoji":"🐐","aliases":["goat"]},{"emoji":"🐪","aliases":["dromedary_camel"]},{"emoji":"🐫","aliases":["camel"]},{"emoji":"🦙","aliases":["llama"]},{"emoji":"🦒","aliases":["giraffe"]},{"emoji":"🐘","aliases":["elephant"]},{"emoji":"🦣","aliases":["mammoth"]},{"emoji":"🦏","aliases":["rhinoceros"]},{"emoji":"🦛","aliases":["hippopotamus"]},{"emoji":"🐭","aliases":["mouse"]},{"emoji":"🐁","aliases":["mouse2"]},{"emoji":"🐀","aliases":["rat"]},{"emoji":"🐹","aliases":["hamster"]},{"emoji":"🐰","aliases":["rabbit"]},{"emoji":"🐇","aliases":["rabbit2"]},{"emoji":"🐿️","aliases":["chipmunk"]},{"emoji":"🦫","aliases":["beaver"]},{"emoji":"🦔","aliases":["hedgehog"]},{"emoji":"🦇","aliases":["bat"]},{"emoji":"🐻","aliases":["bear"]},{"emoji":"🐻‍❄️","aliases":["polar_bear"]},{"emoji":"🐨","aliases":["koala"]},{"emoji":"🐼","aliases":["panda_face"]},{"emoji":"🦥","aliases":["sloth"]},{"emoji":"🦦","aliases":["otter"]},{"emoji":"🦨","aliases":["skunk"]},{"emoji":"🦘","aliases":["kangaroo"]},{"emoji":"🦡","aliases":["badger"]},{"emoji":"🐾","aliases":["feet","paw_prints"]},{"emoji":"🦃","aliases":["turkey"]},{"emoji":"🐔","aliases":["chicken"]},{"emoji":"🐓","aliases":["rooster"]},{"emoji":"🐣","aliases":["hatching_chick"]},{"emoji":"🐤","aliases":["baby_chick"]},{"emoji":"🐥","aliases":["hatched_chick"]},{"emoji":"🐦","aliases":["bird"]},{"emoji":"🐧","aliases":["penguin"]},{"emoji":"🕊️","aliases":["dove"]},{"emoji":"🦅","aliases":["eagle"]},{"emoji":"🦆","aliases":["duck"]},{"emoji":"🦢","aliases":["swan"]},{"emoji":"🦉","aliases":["owl"]},{"emoji":"🦤","aliases":["dodo"]},{"emoji":"🪶","aliases":["feather"]},{"emoji":"🦩","aliases":["flamingo"]},{"emoji":"🦚","aliases":["peacock"]},{"emoji":"🦜","aliases":["parrot"]},{"emoji":"🐸","aliases":["frog"]},{"emoji":"🐊","aliases":["crocodile"]},{"emoji":"🐢","aliases":["turtle"]},{"emoji":"🦎","aliases":["lizard"]},{"emoji":"🐍","aliases":["snake"]},{"emoji":"🐲","aliases":["dragon_face"]},{"emoji":"🐉","aliases":["dragon"]},{"emoji":"🦕","aliases":["sauropod"]},{"emoji":"🦖","aliases":["t-rex"]},{"emoji":"🐳","aliases":["whale"]},{"emoji":"🐋","aliases":["whale2"]},{"emoji":"🐬","aliases":["dolphin","flipper"]},{"emoji":"🦭","aliases":["seal"]},{"emoji":"🐟","aliases":["fish"]},{"emoji":"🐠","aliases":["tropical_fish"]},{"emoji":"🐡","aliases":["blowfish"]},{"emoji":"🦈","aliases":["shark"]},{"emoji":"🐙","aliases":["octopus"]},{"emoji":"🐚","aliases":["shell"]},{"emoji":"🐌","aliases":["snail"]},{"emoji":"🦋","aliases":["butterfly"]},{"emoji":"🐛","aliases":["bug"]},{"emoji":"🐜","aliases":["ant"]},{"emoji":"🐝","aliases":["bee","honeybee"]},{"emoji":"🪲","aliases":["beetle"]},{"emoji":"🐞","aliases":["lady_beetle"]},{"emoji":"🦗","aliases":["cricket"]},{"emoji":"🪳","aliases":["cockroach"]},{"emoji":"🕷️","aliases":["spider"]},{"emoji":"🕸️","aliases":["spider_web"]},{"emoji":"🦂","aliases":["scorpion"]},{"emoji":"🦟","aliases":["mosquito"]},{"emoji":"🪰","aliases":["fly"]},{"emoji":"🪱","aliases":["worm"]},{"emoji":"🦠","aliases":["microbe"]},{"emoji":"💐","aliases":["bouquet"]},{"emoji":"🌸","aliases":["cherry_blossom"]},{"emoji":"💮","aliases":["white_flower"]},{"emoji":"🏵️","aliases":["rosette"]},{"emoji":"🌹","aliases":["rose"]},{"emoji":"🥀","aliases":["wilted_flower"]},{"emoji":"🌺","aliases":["hibiscus"]},{"emoji":"🌻","aliases":["sunflower"]},{"emoji":"🌼","aliases":["blossom"]},{"emoji":"🌷","aliases":["tulip"]},{"emoji":"🌱","aliases":["seedling"]},{"emoji":"🪴","aliases":["potted_plant"]},{"emoji":"🌲","aliases":["evergreen_tree"]},{"emoji":"🌳","aliases":["deciduous_tree"]},{"emoji":"🌴","aliases":["palm_tree"]},{"emoji":"🌵","aliases":["cactus"]},{"emoji":"🌾","aliases":["ear_of_rice"]},{"emoji":"🌿","aliases":["herb"]},{"emoji":"☘️","aliases":["shamrock"]},{"emoji":"🍀","aliases":["four_leaf_clover"]},{"emoji":"🍁","aliases":["maple_leaf"]},{"emoji":"🍂","aliases":["fallen_leaf"]},{"emoji":"🍃","aliases":["leaves"]},{"emoji":"🍇","aliases":["grapes"]},{"emoji":"🍈","aliases":["melon"]},{"emoji":"🍉","aliases":["watermelon"]},{"emoji":"🍊","aliases":["tangerine","orange","mandarin"]},{"emoji":"🍋","aliases":["lemon"]},{"emoji":"🍌","aliases":["banana"]},{"emoji":"🍍","aliases":["pineapple"]},{"emoji":"🥭","aliases":["mango"]},{"emoji":"🍎","aliases":["apple"]},{"emoji":"🍏","aliases":["green_apple"]},{"emoji":"🍐","aliases":["pear"]},{"emoji":"🍑","aliases":["peach"]},{"emoji":"🍒","aliases":["cherries"]},{"emoji":"🍓","aliases":["strawberry"]},{"emoji":"🫐","aliases":["blueberries"]},{"emoji":"🥝","aliases":["kiwi_fruit"]},{"emoji":"🍅","aliases":["tomato"]},{"emoji":"🫒","aliases":["olive"]},{"emoji":"🥥","aliases":["coconut"]},{"emoji":"🥑","aliases":["avocado"]},{"emoji":"🍆","aliases":["eggplant"]},{"emoji":"🥔","aliases":["potato"]},{"emoji":"🥕","aliases":["carrot"]},{"emoji":"🌽","aliases":["corn"]},{"emoji":"🌶️","aliases":["hot_pepper"]},{"emoji":"🫑","aliases":["bell_pepper"]},{"emoji":"🥒","aliases":["cucumber"]},{"emoji":"🥬","aliases":["leafy_green"]},{"emoji":"🥦","aliases":["broccoli"]},{"emoji":"🧄","aliases":["garlic"]},{"emoji":"🧅","aliases":["onion"]},{"emoji":"🍄","aliases":["mushroom"]},{"emoji":"🥜","aliases":["peanuts"]},{"emoji":"🌰","aliases":["chestnut"]},{"emoji":"🍞","aliases":["bread"]},{"emoji":"🥐","aliases":["croissant"]},{"emoji":"🥖","aliases":["baguette_bread"]},{"emoji":"🫓","aliases":["flatbread"]},{"emoji":"🥨","aliases":["pretzel"]},{"emoji":"🥯","aliases":["bagel"]},{"emoji":"🥞","aliases":["pancakes"]},{"emoji":"🧇","aliases":["waffle"]},{"emoji":"🧀","aliases":["cheese"]},{"emoji":"🍖","aliases":["meat_on_bone"]},{"emoji":"🍗","aliases":["poultry_leg"]},{"emoji":"🥩","aliases":["cut_of_meat"]},{"emoji":"🥓","aliases":["bacon"]},{"emoji":"🍔","aliases":["hamburger"]},{"emoji":"🍟","aliases":["fries"]},{"emoji":"🍕","aliases":["pizza"]},{"emoji":"🌭","aliases":["hotdog"]},{"emoji":"🥪","aliases":["sandwich"]},{"emoji":"🌮","aliases":["taco"]},{"emoji":"🌯","aliases":["burrito"]},{"emoji":"🫔","aliases":["tamale"]},{"emoji":"🥙","aliases":["stuffed_flatbread"]},{"emoji":"🧆","aliases":["falafel"]},{"emoji":"🥚","aliases":["egg"]},{"emoji":"🍳","aliases":["fried_egg"]},{"emoji":"🥘","aliases":["shallow_pan_of_food"]},{"emoji":"🍲","aliases":["stew"]},{"emoji":"🫕","aliases":["fondue"]},{"emoji":"🥣","aliases":["bowl_with_spoon"]},{"emoji":"🥗","aliases":["green_salad"]},{"emoji":"🍿","aliases":["popcorn"]},{"emoji":"🧈","aliases":["butter"]},{"emoji":"🧂","aliases":["salt"]},{"emoji":"🥫","aliases":["canned_food"]},{"emoji":"🍱","aliases":["bento"]},{"emoji":"🍘","aliases":["rice_cracker"]},{"emoji":"🍙","aliases":["rice_ball"]},{"emoji":"🍚","aliases":["rice"]},{"emoji":"🍛","aliases":["curry"]},{"emoji":"🍜","aliases":["ramen"]},{"emoji":"🍝","aliases":["spaghetti"]},{"emoji":"🍠","aliases":["sweet_potato"]},{"emoji":"🍢","aliases":["oden"]},{"emoji":"🍣","aliases":["sushi"]},{"emoji":"🍤","aliases":["fried_shrimp"]},{"emoji":"🍥","aliases":["fish_cake"]},{"emoji":"🥮","aliases":["moon_cake"]},{"emoji":"🍡","aliases":["dango"]},{"emoji":"🥟","aliases":["dumpling"]},{"emoji":"🥠","aliases":["fortune_cookie"]},{"emoji":"🥡","aliases":["takeout_box"]},{"emoji":"🦀","aliases":["crab"]},{"emoji":"🦞","aliases":["lobster"]},{"emoji":"🦐","aliases":["shrimp"]},{"emoji":"🦑","aliases":["squid"]},{"emoji":"🦪","aliases":["oyster"]},{"emoji":"🍦","aliases":["icecream"]},{"emoji":"🍧","aliases":["shaved_ice"]},{"emoji":"🍨","aliases":["ice_cream"]},{"emoji":"🍩","aliases":["doughnut"]},{"emoji":"🍪","aliases":["cookie"]},{"emoji":"🎂","aliases":["birthday"]},{"emoji":"🍰","aliases":["cake"]},{"emoji":"🧁","aliases":["cupcake"]},{"emoji":"🥧","aliases":["pie"]},{"emoji":"🍫","aliases":["chocolate_bar"]},{"emoji":"🍬","aliases":["candy"]},{"emoji":"🍭","aliases":["lollipop"]},{"emoji":"🍮","aliases":["custard"]},{"emoji":"🍯","aliases":["honey_pot"]},{"emoji":"🍼","aliases":["baby_bottle"]},{"emoji":"🥛","aliases":["milk_glass"]},{"emoji":"☕","aliases":["coffee"]},{"emoji":"🫖","aliases":["teapot"]},{"emoji":"🍵","aliases":["tea"]},{"emoji":"🍶","aliases":["sake"]},{"emoji":"🍾","aliases":["champagne"]},{"emoji":"🍷","aliases":["wine_glass"]},{"emoji":"🍸","aliases":["cocktail"]},{"emoji":"🍹","aliases":["tropical_drink"]},{"emoji":"🍺","aliases":["beer"]},{"emoji":"🍻","aliases":["beers"]},{"emoji":"🥂","aliases":["clinking_glasses"]},{"emoji":"🥃","aliases":["tumbler_glass"]},{"emoji":"🥤","aliases":["cup_with_straw"]},{"emoji":"🧋","aliases":["bubble_tea"]},{"emoji":"🧃","aliases":["beverage_box"]},{"emoji":"🧉","aliases":["mate"]},{"emoji":"🧊","aliases":["ice_cube"]},{"emoji":"🥢","aliases":["chopsticks"]},{"emoji":"🍽️","aliases":["plate_with_cutlery"]},{"emoji":"🍴","aliases":["fork_and_knife"]},{"emoji":"🥄","aliases":["spoon"]},{"emoji":"🔪","aliases":["hocho","knife"]},{"emoji":"🏺","aliases":["amphora"]},{"emoji":"🌍","aliases":["earth_africa"]},{"emoji":"🌎","aliases":["earth_americas"]},{"emoji":"🌏","aliases":["earth_asia"]},{"emoji":"🌐","aliases":["globe_with_meridians"]},{"emoji":"🗺️","aliases":["world_map"]},{"emoji":"🗾","aliases":["japan"]},{"emoji":"🧭","aliases":["compass"]},{"emoji":"🏔️","aliases":["mountain_snow"]},{"emoji":"⛰️","aliases":["mountain"]},{"emoji":"🌋","aliases":["volcano"]},{"emoji":"🗻","aliases":["mount_fuji"]},{"emoji":"🏕️","aliases":["camping"]},{"emoji":"🏖️","aliases":["beach_umbrella"]},{"emoji":"🏜️","aliases":["desert"]},{"emoji":"🏝️","aliases":["desert_island"]},{"emoji":"🏞️","aliases":["national_park"]},{"emoji":"🏟️","aliases":["stadium"]},{"emoji":"🏛️","aliases":["classical_building"]},{"emoji":"🏗️","aliases":["building_construction"]},{"emoji":"🧱","aliases":["bricks"]},{"emoji":"🪨","aliases":["rock"]},{"emoji":"🪵","aliases":["wood"]},{"emoji":"🛖","aliases":["hut"]},{"emoji":"🏘️","aliases":["houses"]},{"emoji":"🏚️","aliases":["derelict_house"]},{"emoji":"🏠","aliases":["house"]},{"emoji":"🏡","aliases":["house_with_garden"]},{"emoji":"🏢","aliases":["office"]},{"emoji":"🏣","aliases":["post_office"]},{"emoji":"🏤","aliases":["european_post_office"]},{"emoji":"🏥","aliases":["hospital"]},{"emoji":"🏦","aliases":["bank"]},{"emoji":"🏨","aliases":["hotel"]},{"emoji":"🏩","aliases":["love_hotel"]},{"emoji":"🏪","aliases":["convenience_store"]},{"emoji":"🏫","aliases":["school"]},{"emoji":"🏬","aliases":["department_store"]},{"emoji":"🏭","aliases":["factory"]},{"emoji":"🏯","aliases":["japanese_castle"]},{"emoji":"🏰","aliases":["european_castle"]},{"emoji":"💒","aliases":["wedding"]},{"emoji":"🗼","aliases":["tokyo_tower"]},{"emoji":"🗽","aliases":["statue_of_liberty"]},{"emoji":"⛪","aliases":["church"]},{"emoji":"🕌","aliases":["mosque"]},{"emoji":"🛕","aliases":["hindu_temple"]},{"emoji":"🕍","aliases":["synagogue"]},{"emoji":"⛩️","aliases":["shinto_shrine"]},{"emoji":"🕋","aliases":["kaaba"]},{"emoji":"⛲","aliases":["fountain"]},{"emoji":"⛺","aliases":["tent"]},{"emoji":"🌁","aliases":["foggy"]},{"emoji":"🌃","aliases":["night_with_stars"]},{"emoji":"🏙️","aliases":["cityscape"]},{"emoji":"🌄","aliases":["sunrise_over_mountains"]},{"emoji":"🌅","aliases":["sunrise"]},{"emoji":"🌆","aliases":["city_sunset"]},{"emoji":"🌇","aliases":["city_sunrise"]},{"emoji":"🌉","aliases":["bridge_at_night"]},{"emoji":"♨️","aliases":["hotsprings"]},{"emoji":"🎠","aliases":["carousel_horse"]},{"emoji":"🎡","aliases":["ferris_wheel"]},{"emoji":"🎢","aliases":["roller_coaster"]},{"emoji":"💈","aliases":["barber"]},{"emoji":"🎪","aliases":["circus_tent"]},{"emoji":"🚂","aliases":["steam_locomotive"]},{"emoji":"🚃","aliases":["railway_car"]},{"emoji":"🚄","aliases":["bullettrain_side"]},{"emoji":"🚅","aliases":["bullettrain_front"]},{"emoji":"🚆","aliases":["train2"]},{"emoji":"🚇","aliases":["metro"]},{"emoji":"🚈","aliases":["light_rail"]},{"emoji":"🚉","aliases":["station"]},{"emoji":"🚊","aliases":["tram"]},{"emoji":"🚝","aliases":["monorail"]},{"emoji":"🚞","aliases":["mountain_railway"]},{"emoji":"🚋","aliases":["train"]},{"emoji":"🚌","aliases":["bus"]},{"emoji":"🚍","aliases":["oncoming_bus"]},{"emoji":"🚎","aliases":["trolleybus"]},{"emoji":"🚐","aliases":["minibus"]},{"emoji":"🚑","aliases":["ambulance"]},{"emoji":"🚒","aliases":["fire_engine"]},{"emoji":"🚓","aliases":["police_car"]},{"emoji":"🚔","aliases":["oncoming_police_car"]},{"emoji":"🚕","aliases":["taxi"]},{"emoji":"🚖","aliases":["oncoming_taxi"]},{"emoji":"🚗","aliases":["car","red_car"]},{"emoji":"🚘","aliases":["oncoming_automobile"]},{"emoji":"🚙","aliases":["blue_car"]},{"emoji":"🛻","aliases":["pickup_truck"]},{"emoji":"🚚","aliases":["truck"]},{"emoji":"🚛","aliases":["articulated_lorry"]},{"emoji":"🚜","aliases":["tractor"]},{"emoji":"🏎️","aliases":["racing_car"]},{"emoji":"🏍️","aliases":["motorcycle"]},{"emoji":"🛵","aliases":["motor_scooter"]},{"emoji":"🦽","aliases":["manual_wheelchair"]},{"emoji":"🦼","aliases":["motorized_wheelchair"]},{"emoji":"🛺","aliases":["auto_rickshaw"]},{"emoji":"🚲","aliases":["bike"]},{"emoji":"🛴","aliases":["kick_scooter"]},{"emoji":"🛹","aliases":["skateboard"]},{"emoji":"🛼","aliases":["roller_skate"]},{"emoji":"🚏","aliases":["busstop"]},{"emoji":"🛣️","aliases":["motorway"]},{"emoji":"🛤️","aliases":["railway_track"]},{"emoji":"🛢️","aliases":["oil_drum"]},{"emoji":"⛽","aliases":["fuelpump"]},{"emoji":"🚨","aliases":["rotating_light"]},{"emoji":"🚥","aliases":["traffic_light"]},{"emoji":"🚦","aliases":["vertical_traffic_light"]},{"emoji":"🛑","aliases":["stop_sign"]},{"emoji":"🚧","aliases":["construction"]},{"emoji":"⚓","aliases":["anchor"]},{"emoji":"⛵","aliases":["boat","sailboat"]},{"emoji":"🛶","aliases":["canoe"]},{"emoji":"🚤","aliases":["speedboat"]},{"emoji":"🛳️","aliases":["passenger_ship"]},{"emoji":"⛴️","aliases":["ferry"]},{"emoji":"🛥️","aliases":["motor_boat"]},{"emoji":"🚢","aliases":["ship"]},{"emoji":"✈️","aliases":["airplane"]},{"emoji":"🛩️","aliases":["small_airplane"]},{"emoji":"🛫","aliases":["flight_departure"]},{"emoji":"🛬","aliases":["flight_arrival"]},{"emoji":"🪂","aliases":["parachute"]},{"emoji":"💺","aliases":["seat"]},{"emoji":"🚁","aliases":["helicopter"]},{"emoji":"🚟","aliases":["suspension_railway"]},{"emoji":"🚠","aliases":["mountain_cableway"]},{"emoji":"🚡","aliases":["aerial_tramway"]},{"emoji":"🛰️","aliases":["artificial_satellite"]},{"emoji":"🚀","aliases":["rocket"]},{"emoji":"🛸","aliases":["flying_saucer"]},{"emoji":"🛎️","aliases":["bellhop_bell"]},{"emoji":"🧳","aliases":["luggage"]},{"emoji":"⌛","aliases":["hourglass"]},{"emoji":"⏳","aliases":["hourglass_flowing_sand"]},{"emoji":"⌚","aliases":["watch"]},{"emoji":"⏰","aliases":["alarm_clock"]},{"emoji":"⏱️","aliases":["stopwatch"]},{"emoji":"⏲️","aliases":["timer_clock"]},{"emoji":"🕰️","aliases":["mantelpiece_clock"]},{"emoji":"🕛","aliases":["clock12"]},{"emoji":"🕧","aliases":["clock1230"]},{"emoji":"🕐","aliases":["clock1"]},{"emoji":"🕜","aliases":["clock130"]},{"emoji":"🕑","aliases":["clock2"]},{"emoji":"🕝","aliases":["clock230"]},{"emoji":"🕒","aliases":["clock3"]},{"emoji":"🕞","aliases":["clock330"]},{"emoji":"🕓","aliases":["clock4"]},{"emoji":"🕟","aliases":["clock430"]},{"emoji":"🕔","aliases":["clock5"]},{"emoji":"🕠","aliases":["clock530"]},{"emoji":"🕕","aliases":["clock6"]},{"emoji":"🕡","aliases":["clock630"]},{"emoji":"🕖","aliases":["clock7"]},{"emoji":"🕢","aliases":["clock730"]},{"emoji":"🕗","aliases":["clock8"]},{"emoji":"🕣","aliases":["clock830"]},{"emoji":"🕘","aliases":["clock9"]},{"emoji":"🕤","aliases":["clock930"]},{"emoji":"🕙","aliases":["clock10"]},{"emoji":"🕥","aliases":["clock1030"]},{"emoji":"🕚","aliases":["clock11"]},{"emoji":"🕦","aliases":["clock1130"]},{"emoji":"🌑","aliases":["new_moon"]},{"emoji":"🌒","aliases":["waxing_crescent_moon"]},{"emoji":"🌓","aliases":["first_quarter_moon"]},{"emoji":"🌔","aliases":["moon","waxing_gibbous_moon"]},{"emoji":"🌕","aliases":["full_moon"]},{"emoji":"🌖","aliases":["waning_gibbous_moon"]},{"emoji":"🌗","aliases":["last_quarter_moon"]},{"emoji":"🌘","aliases":["waning_crescent_moon"]},{"emoji":"🌙","aliases":["crescent_moon"]},{"emoji":"🌚","aliases":["new_moon_with_face"]},{"emoji":"🌛","aliases":["first_quarter_moon_with_face"]},{"emoji":"🌜","aliases":["last_quarter_moon_with_face"]},{"emoji":"🌡️","aliases":["thermometer"]},{"emoji":"☀️","aliases":["sunny"]},{"emoji":"🌝","aliases":["full_moon_with_face"]},{"emoji":"🌞","aliases":["sun_with_face"]},{"emoji":"🪐","aliases":["ringed_planet"]},{"emoji":"⭐","aliases":["star"]},{"emoji":"🌟","aliases":["star2"]},{"emoji":"🌠","aliases":["stars"]},{"emoji":"🌌","aliases":["milky_way"]},{"emoji":"☁️","aliases":["cloud"]},{"emoji":"⛅","aliases":["partly_sunny"]},{"emoji":"⛈️","aliases":["cloud_with_lightning_and_rain"]},{"emoji":"🌤️","aliases":["sun_behind_small_cloud"]},{"emoji":"🌥️","aliases":["sun_behind_large_cloud"]},{"emoji":"🌦️","aliases":["sun_behind_rain_cloud"]},{"emoji":"🌧️","aliases":["cloud_with_rain"]},{"emoji":"🌨️","aliases":["cloud_with_snow"]},{"emoji":"🌩️","aliases":["cloud_with_lightning"]},{"emoji":"🌪️","aliases":["tornado"]},{"emoji":"🌫️","aliases":["fog"]},{"emoji":"🌬️","aliases":["wind_face"]},{"emoji":"🌀","aliases":["cyclone"]},{"emoji":"🌈","aliases":["rainbow"]},{"emoji":"🌂","aliases":["closed_umbrella"]},{"emoji":"☂️","aliases":["open_umbrella"]},{"emoji":"☔","aliases":["umbrella"]},{"emoji":"⛱️","aliases":["parasol_on_ground"]},{"emoji":"⚡","aliases":["zap"]},{"emoji":"❄️","aliases":["snowflake"]},{"emoji":"☃️","aliases":["snowman_with_snow"]},{"emoji":"⛄","aliases":["snowman"]},{"emoji":"☄️","aliases":["comet"]},{"emoji":"🔥","aliases":["fire"]},{"emoji":"💧","aliases":["droplet"]},{"emoji":"🌊","aliases":["ocean"]},{"emoji":"🎃","aliases":["jack_o_lantern"]},{"emoji":"🎄","aliases":["christmas_tree"]},{"emoji":"🎆","aliases":["fireworks"]},{"emoji":"🎇","aliases":["sparkler"]},{"emoji":"🧨","aliases":["firecracker"]},{"emoji":"✨","aliases":["sparkles"]},{"emoji":"🎈","aliases":["balloon"]},{"emoji":"🎉","aliases":["tada"]},{"emoji":"🎊","aliases":["confetti_ball"]},{"emoji":"🎋","aliases":["tanabata_tree"]},{"emoji":"🎍","aliases":["bamboo"]},{"emoji":"🎎","aliases":["dolls"]},{"emoji":"🎏","aliases":["flags"]},{"emoji":"🎐","aliases":["wind_chime"]},{"emoji":"🎑","aliases":["rice_scene"]},{"emoji":"🧧","aliases":["red_envelope"]},{"emoji":"🎀","aliases":["ribbon"]},{"emoji":"🎁","aliases":["gift"]},{"emoji":"🎗️","aliases":["reminder_ribbon"]},{"emoji":"🎟️","aliases":["tickets"]},{"emoji":"🎫","aliases":["ticket"]},{"emoji":"🎖️","aliases":["medal_military"]},{"emoji":"🏆","aliases":["trophy"]},{"emoji":"🏅","aliases":["medal_sports"]},{"emoji":"🥇","aliases":["1st_place_medal"]},{"emoji":"🥈","aliases":["2nd_place_medal"]},{"emoji":"🥉","aliases":["3rd_place_medal"]},{"emoji":"⚽","aliases":["soccer"]},{"emoji":"⚾","aliases":["baseball"]},{"emoji":"🥎","aliases":["softball"]},{"emoji":"🏀","aliases":["basketball"]},{"emoji":"🏐","aliases":["volleyball"]},{"emoji":"🏈","aliases":["football"]},{"emoji":"🏉","aliases":["rugby_football"]},{"emoji":"🎾","aliases":["tennis"]},{"emoji":"🥏","aliases":["flying_disc"]},{"emoji":"🎳","aliases":["bowling"]},{"emoji":"🏏","aliases":["cricket_game"]},{"emoji":"🏑","aliases":["field_hockey"]},{"emoji":"🏒","aliases":["ice_hockey"]},{"emoji":"🥍","aliases":["lacrosse"]},{"emoji":"🏓","aliases":["ping_pong"]},{"emoji":"🏸","aliases":["badminton"]},{"emoji":"🥊","aliases":["boxing_glove"]},{"emoji":"🥋","aliases":["martial_arts_uniform"]},{"emoji":"🥅","aliases":["goal_net"]},{"emoji":"⛳","aliases":["golf"]},{"emoji":"⛸️","aliases":["ice_skate"]},{"emoji":"🎣","aliases":["fishing_pole_and_fish"]},{"emoji":"🤿","aliases":["diving_mask"]},{"emoji":"🎽","aliases":["running_shirt_with_sash"]},{"emoji":"🎿","aliases":["ski"]},{"emoji":"🛷","aliases":["sled"]},{"emoji":"🥌","aliases":["curling_stone"]},{"emoji":"🎯","aliases":["dart"]},{"emoji":"🪀","aliases":["yo_yo"]},{"emoji":"🪁","aliases":["kite"]},{"emoji":"🎱","aliases":["8ball"]},{"emoji":"🔮","aliases":["crystal_ball"]},{"emoji":"🪄","aliases":["magic_wand"]},{"emoji":"🧿","aliases":["nazar_amulet"]},{"emoji":"🎮","aliases":["video_game"]},{"emoji":"🕹️","aliases":["joystick"]},{"emoji":"🎰","aliases":["slot_machine"]},{"emoji":"🎲","aliases":["game_die"]},{"emoji":"🧩","aliases":["jigsaw"]},{"emoji":"🧸","aliases":["teddy_bear"]},{"emoji":"🪅","aliases":["pinata"]},{"emoji":"🪆","aliases":["nesting_dolls"]},{"emoji":"♠️","aliases":["spades"]},{"emoji":"♥️","aliases":["hearts"]},{"emoji":"♦️","aliases":["diamonds"]},{"emoji":"♣️","aliases":["clubs"]},{"emoji":"♟️","aliases":["chess_pawn"]},{"emoji":"🃏","aliases":["black_joker"]},{"emoji":"🀄","aliases":["mahjong"]},{"emoji":"🎴","aliases":["flower_playing_cards"]},{"emoji":"🎭","aliases":["performing_arts"]},{"emoji":"🖼️","aliases":["framed_picture"]},{"emoji":"🎨","aliases":["art"]},{"emoji":"🧵","aliases":["thread"]},{"emoji":"🪡","aliases":["sewing_needle"]},{"emoji":"🧶","aliases":["yarn"]},{"emoji":"🪢","aliases":["knot"]},{"emoji":"👓","aliases":["eyeglasses"]},{"emoji":"🕶️","aliases":["dark_sunglasses"]},{"emoji":"🥽","aliases":["goggles"]},{"emoji":"🥼","aliases":["lab_coat"]},{"emoji":"🦺","aliases":["safety_vest"]},{"emoji":"👔","aliases":["necktie"]},{"emoji":"👕","aliases":["shirt","tshirt"]},{"emoji":"👖","aliases":["jeans"]},{"emoji":"🧣","aliases":["scarf"]},{"emoji":"🧤","aliases":["gloves"]},{"emoji":"🧥","aliases":["coat"]},{"emoji":"🧦","aliases":["socks"]},{"emoji":"👗","aliases":["dress"]},{"emoji":"👘","aliases":["kimono"]},{"emoji":"🥻","aliases":["sari"]},{"emoji":"🩱","aliases":["one_piece_swimsuit"]},{"emoji":"🩲","aliases":["swim_brief"]},{"emoji":"🩳","aliases":["shorts"]},{"emoji":"👙","aliases":["bikini"]},{"emoji":"👚","aliases":["womans_clothes"]},{"emoji":"👛","aliases":["purse"]},{"emoji":"👜","aliases":["handbag"]},{"emoji":"👝","aliases":["pouch"]},{"emoji":"🛍️","aliases":["shopping"]},{"emoji":"🎒","aliases":["school_satchel"]},{"emoji":"🩴","aliases":["thong_sandal"]},{"emoji":"👞","aliases":["mans_shoe","shoe"]},{"emoji":"👟","aliases":["athletic_shoe"]},{"emoji":"🥾","aliases":["hiking_boot"]},{"emoji":"🥿","aliases":["flat_shoe"]},{"emoji":"👠","aliases":["high_heel"]},{"emoji":"👡","aliases":["sandal"]},{"emoji":"🩰","aliases":["ballet_shoes"]},{"emoji":"👢","aliases":["boot"]},{"emoji":"👑","aliases":["crown"]},{"emoji":"👒","aliases":["womans_hat"]},{"emoji":"🎩","aliases":["tophat"]},{"emoji":"🎓","aliases":["mortar_board"]},{"emoji":"🧢","aliases":["billed_cap"]},{"emoji":"🪖","aliases":["military_helmet"]},{"emoji":"⛑️","aliases":["rescue_worker_helmet"]},{"emoji":"📿","aliases":["prayer_beads"]},{"emoji":"💄","aliases":["lipstick"]},{"emoji":"💍","aliases":["ring"]},{"emoji":"💎","aliases":["gem"]},{"emoji":"🔇","aliases":["mute"]},{"emoji":"🔈","aliases":["speaker"]},{"emoji":"🔉","aliases":["sound"]},{"emoji":"🔊","aliases":["loud_sound"]},{"emoji":"📢","aliases":["loudspeaker"]},{"emoji":"📣","aliases":["mega"]},{"emoji":"📯","aliases":["postal_horn"]},{"emoji":"🔔","aliases":["bell"]},{"emoji":"🔕","aliases":["no_bell"]},{"emoji":"🎼","aliases":["musical_score"]},{"emoji":"🎵","aliases":["musical_note"]},{"emoji":"🎶","aliases":["notes"]},{"emoji":"🎙️","aliases":["studio_microphone"]},{"emoji":"🎚️","aliases":["level_slider"]},{"emoji":"🎛️","aliases":["control_knobs"]},{"emoji":"🎤","aliases":["microphone"]},{"emoji":"🎧","aliases":["headphones"]},{"emoji":"📻","aliases":["radio"]},{"emoji":"🎷","aliases":["saxophone"]},{"emoji":"🪗","aliases":["accordion"]},{"emoji":"🎸","aliases":["guitar"]},{"emoji":"🎹","aliases":["musical_keyboard"]},{"emoji":"🎺","aliases":["trumpet"]},{"emoji":"🎻","aliases":["violin"]},{"emoji":"🪕","aliases":["banjo"]},{"emoji":"🥁","aliases":["drum"]},{"emoji":"🪘","aliases":["long_drum"]},{"emoji":"📱","aliases":["iphone"]},{"emoji":"📲","aliases":["calling"]},{"emoji":"☎️","aliases":["phone","telephone"]},{"emoji":"📞","aliases":["telephone_receiver"]},{"emoji":"📟","aliases":["pager"]},{"emoji":"📠","aliases":["fax"]},{"emoji":"🔋","aliases":["battery"]},{"emoji":"🔌","aliases":["electric_plug"]},{"emoji":"💻","aliases":["computer"]},{"emoji":"🖥️","aliases":["desktop_computer"]},{"emoji":"🖨️","aliases":["printer"]},{"emoji":"⌨️","aliases":["keyboard"]},{"emoji":"🖱️","aliases":["computer_mouse"]},{"emoji":"🖲️","aliases":["trackball"]},{"emoji":"💽","aliases":["minidisc"]},{"emoji":"💾","aliases":["floppy_disk"]},{"emoji":"💿","aliases":["cd"]},{"emoji":"📀","aliases":["dvd"]},{"emoji":"🧮","aliases":["abacus"]},{"emoji":"🎥","aliases":["movie_camera"]},{"emoji":"🎞️","aliases":["film_strip"]},{"emoji":"📽️","aliases":["film_projector"]},{"emoji":"🎬","aliases":["clapper"]},{"emoji":"📺","aliases":["tv"]},{"emoji":"📷","aliases":["camera"]},{"emoji":"📸","aliases":["camera_flash"]},{"emoji":"📹","aliases":["video_camera"]},{"emoji":"📼","aliases":["vhs"]},{"emoji":"🔍","aliases":["mag"]},{"emoji":"🔎","aliases":["mag_right"]},{"emoji":"🕯️","aliases":["candle"]},{"emoji":"💡","aliases":["bulb"]},{"emoji":"🔦","aliases":["flashlight"]},{"emoji":"🏮","aliases":["izakaya_lantern","lantern"]},{"emoji":"🪔","aliases":["diya_lamp"]},{"emoji":"📔","aliases":["notebook_with_decorative_cover"]},{"emoji":"📕","aliases":["closed_book"]},{"emoji":"📖","aliases":["book","open_book"]},{"emoji":"📗","aliases":["green_book"]},{"emoji":"📘","aliases":["blue_book"]},{"emoji":"📙","aliases":["orange_book"]},{"emoji":"📚","aliases":["books"]},{"emoji":"📓","aliases":["notebook"]},{"emoji":"📒","aliases":["ledger"]},{"emoji":"📃","aliases":["page_with_curl"]},{"emoji":"📜","aliases":["scroll"]},{"emoji":"📄","aliases":["page_facing_up"]},{"emoji":"📰","aliases":["newspaper"]},{"emoji":"🗞️","aliases":["newspaper_roll"]},{"emoji":"📑","aliases":["bookmark_tabs"]},{"emoji":"🔖","aliases":["bookmark"]},{"emoji":"🏷️","aliases":["label"]},{"emoji":"💰","aliases":["moneybag"]},{"emoji":"🪙","aliases":["coin"]},{"emoji":"💴","aliases":["yen"]},{"emoji":"💵","aliases":["dollar"]},{"emoji":"💶","aliases":["euro"]},{"emoji":"💷","aliases":["pound"]},{"emoji":"💸","aliases":["money_with_wings"]},{"emoji":"💳","aliases":["credit_card"]},{"emoji":"🧾","aliases":["receipt"]},{"emoji":"💹","aliases":["chart"]},{"emoji":"✉️","aliases":["envelope"]},{"emoji":"📧","aliases":["email","e-mail"]},{"emoji":"📨","aliases":["incoming_envelope"]},{"emoji":"📩","aliases":["envelope_with_arrow"]},{"emoji":"📤","aliases":["outbox_tray"]},{"emoji":"📥","aliases":["inbox_tray"]},{"emoji":"📦","aliases":["package"]},{"emoji":"📫","aliases":["mailbox"]},{"emoji":"📪","aliases":["mailbox_closed"]},{"emoji":"📬","aliases":["mailbox_with_mail"]},{"emoji":"📭","aliases":["mailbox_with_no_mail"]},{"emoji":"📮","aliases":["postbox"]},{"emoji":"🗳️","aliases":["ballot_box"]},{"emoji":"✏️","aliases":["pencil2"]},{"emoji":"✒️","aliases":["black_nib"]},{"emoji":"🖋️","aliases":["fountain_pen"]},{"emoji":"🖊️","aliases":["pen"]},{"emoji":"🖌️","aliases":["paintbrush"]},{"emoji":"🖍️","aliases":["crayon"]},{"emoji":"📝","aliases":["memo","pencil"]},{"emoji":"💼","aliases":["briefcase"]},{"emoji":"📁","aliases":["file_folder"]},{"emoji":"📂","aliases":["open_file_folder"]},{"emoji":"🗂️","aliases":["card_index_dividers"]},{"emoji":"📅","aliases":["date"]},{"emoji":"📆","aliases":["calendar"]},{"emoji":"🗒️","aliases":["spiral_notepad"]},{"emoji":"🗓️","aliases":["spiral_calendar"]},{"emoji":"📇","aliases":["card_index"]},{"emoji":"📈","aliases":["chart_with_upwards_trend"]},{"emoji":"📉","aliases":["chart_with_downwards_trend"]},{"emoji":"📊","aliases":["bar_chart"]},{"emoji":"📋","aliases":["clipboard"]},{"emoji":"📌","aliases":["pushpin"]},{"emoji":"📍","aliases":["round_pushpin"]},{"emoji":"📎","aliases":["paperclip"]},{"emoji":"🖇️","aliases":["paperclips"]},{"emoji":"📏","aliases":["straight_ruler"]},{"emoji":"📐","aliases":["triangular_ruler"]},{"emoji":"✂️","aliases":["scissors"]},{"emoji":"🗃️","aliases":["card_file_box"]},{"emoji":"🗄️","aliases":["file_cabinet"]},{"emoji":"🗑️","aliases":["wastebasket"]},{"emoji":"🔒","aliases":["lock"]},{"emoji":"🔓","aliases":["unlock"]},{"emoji":"🔏","aliases":["lock_with_ink_pen"]},{"emoji":"🔐","aliases":["closed_lock_with_key"]},{"emoji":"🔑","aliases":["key"]},{"emoji":"🗝️","aliases":["old_key"]},{"emoji":"🔨","aliases":["hammer"]},{"emoji":"🪓","aliases":["axe"]},{"emoji":"⛏️","aliases":["pick"]},{"emoji":"⚒️","aliases":["hammer_and_pick"]},{"emoji":"🛠️","aliases":["hammer_and_wrench"]},{"emoji":"🗡️","aliases":["dagger"]},{"emoji":"⚔️","aliases":["crossed_swords"]},{"emoji":"🔫","aliases":["gun"]},{"emoji":"🪃","aliases":["boomerang"]},{"emoji":"🏹","aliases":["bow_and_arrow"]},{"emoji":"🛡️","aliases":["shield"]},{"emoji":"🪚","aliases":["carpentry_saw"]},{"emoji":"🔧","aliases":["wrench"]},{"emoji":"🪛","aliases":["screwdriver"]},{"emoji":"🔩","aliases":["nut_and_bolt"]},{"emoji":"⚙️","aliases":["gear"]},{"emoji":"🗜️","aliases":["clamp"]},{"emoji":"⚖️","aliases":["balance_scale"]},{"emoji":"🦯","aliases":["probing_cane"]},{"emoji":"🔗","aliases":["link"]},{"emoji":"⛓️","aliases":["chains"]},{"emoji":"🪝","aliases":["hook"]},{"emoji":"🧰","aliases":["toolbox"]},{"emoji":"🧲","aliases":["magnet"]},{"emoji":"🪜","aliases":["ladder"]},{"emoji":"⚗️","aliases":["alembic"]},{"emoji":"🧪","aliases":["test_tube"]},{"emoji":"🧫","aliases":["petri_dish"]},{"emoji":"🧬","aliases":["dna"]},{"emoji":"🔬","aliases":["microscope"]},{"emoji":"🔭","aliases":["telescope"]},{"emoji":"📡","aliases":["satellite"]},{"emoji":"💉","aliases":["syringe"]},{"emoji":"🩸","aliases":["drop_of_blood"]},{"emoji":"💊","aliases":["pill"]},{"emoji":"🩹","aliases":["adhesive_bandage"]},{"emoji":"🩺","aliases":["stethoscope"]},{"emoji":"🚪","aliases":["door"]},{"emoji":"🛗","aliases":["elevator"]},{"emoji":"🪞","aliases":["mirror"]},{"emoji":"🪟","aliases":["window"]},{"emoji":"🛏️","aliases":["bed"]},{"emoji":"🛋️","aliases":["couch_and_lamp"]},{"emoji":"🪑","aliases":["chair"]},{"emoji":"🚽","aliases":["toilet"]},{"emoji":"🪠","aliases":["plunger"]},{"emoji":"🚿","aliases":["shower"]},{"emoji":"🛁","aliases":["bathtub"]},{"emoji":"🪤","aliases":["mouse_trap"]},{"emoji":"🪒","aliases":["razor"]},{"emoji":"🧴","aliases":["lotion_bottle"]},{"emoji":"🧷","aliases":["safety_pin"]},{"emoji":"🧹","aliases":["broom"]},{"emoji":"🧺","aliases":["basket"]},{"emoji":"🧻","aliases":["roll_of_paper"]},{"emoji":"🪣","aliases":["bucket"]},{"emoji":"🧼","aliases":["soap"]},{"emoji":"🪥","aliases":["toothbrush"]},{"emoji":"🧽","aliases":["sponge"]},{"emoji":"🧯","aliases":["fire_extinguisher"]},{"emoji":"🛒","aliases":["shopping_cart"]},{"emoji":"🚬","aliases":["smoking"]},{"emoji":"⚰️","aliases":["coffin"]},{"emoji":"🪦","aliases":["headstone"]},{"emoji":"⚱️","aliases":["funeral_urn"]},{"emoji":"🗿","aliases":["moyai"]},{"emoji":"🪧","aliases":["placard"]},{"emoji":"🏧","aliases":["atm"]},{"emoji":"🚮","aliases":["put_litter_in_its_place"]},{"emoji":"🚰","aliases":["potable_water"]},{"emoji":"♿","aliases":["wheelchair"]},{"emoji":"🚹","aliases":["mens"]},{"emoji":"🚺","aliases":["womens"]},{"emoji":"🚻","aliases":["restroom"]},{"emoji":"🚼","aliases":["baby_symbol"]},{"emoji":"🚾","aliases":["wc"]},{"emoji":"🛂","aliases":["passport_control"]},{"emoji":"🛃","aliases":["customs"]},{"emoji":"🛄","aliases":["baggage_claim"]},{"emoji":"🛅","aliases":["left_luggage"]},{"emoji":"⚠️","aliases":["warning"]},{"emoji":"🚸","aliases":["children_crossing"]},{"emoji":"⛔","aliases":["no_entry"]},{"emoji":"🚫","aliases":["no_entry_sign"]},{"emoji":"🚳","aliases":["no_bicycles"]},{"emoji":"🚭","aliases":["no_smoking"]},{"emoji":"🚯","aliases":["do_not_litter"]},{"emoji":"🚱","aliases":["non-potable_water"]},{"emoji":"🚷","aliases":["no_pedestrians"]},{"emoji":"📵","aliases":["no_mobile_phones"]},{"emoji":"🔞","aliases":["underage"]},{"emoji":"☢️","aliases":["radioactive"]},{"emoji":"☣️","aliases":["biohazard"]},{"emoji":"⬆️","aliases":["arrow_up"]},{"emoji":"↗️","aliases":["arrow_upper_right"]},{"emoji":"➡️","aliases":["arrow_right"]},{"emoji":"↘️","aliases":["arrow_lower_right"]},{"emoji":"⬇️","aliases":["arrow_down"]},{"emoji":"↙️","aliases":["arrow_lower_left"]},{"emoji":"⬅️","aliases":["arrow_left"]},{"emoji":"↖️","aliases":["arrow_upper_left"]},{"emoji":"↕️","aliases":["arrow_up_down"]},{"emoji":"↔️","aliases":["left_right_arrow"]},{"emoji":"↩️","aliases":["leftwards_arrow_with_hook"]},{"emoji":"↪️","aliases":["arrow_right_hook"]},{"emoji":"⤴️","aliases":["arrow_heading_up"]},{"emoji":"⤵️","aliases":["arrow_heading_down"]},{"emoji":"🔃","aliases":["arrows_clockwise"]},{"emoji":"🔄","aliases":["arrows_counterclockwise"]},{"emoji":"🔙","aliases":["back"]},{"emoji":"🔚","aliases":["end"]},{"emoji":"🔛","aliases":["on"]},{"emoji":"🔜","aliases":["soon"]},{"emoji":"🔝","aliases":["top"]},{"emoji":"🛐","aliases":["place_of_worship"]},{"emoji":"⚛️","aliases":["atom_symbol"]},{"emoji":"🕉️","aliases":["om"]},{"emoji":"✡️","aliases":["star_of_david"]},{"emoji":"☸️","aliases":["wheel_of_dharma"]},{"emoji":"☯️","aliases":["yin_yang"]},{"emoji":"✝️","aliases":["latin_cross"]},{"emoji":"☦️","aliases":["orthodox_cross"]},{"emoji":"☪️","aliases":["star_and_crescent"]},{"emoji":"☮️","aliases":["peace_symbol"]},{"emoji":"🕎","aliases":["menorah"]},{"emoji":"🔯","aliases":["six_pointed_star"]},{"emoji":"♈","aliases":["aries"]},{"emoji":"♉","aliases":["taurus"]},{"emoji":"♊","aliases":["gemini"]},{"emoji":"♋","aliases":["cancer"]},{"emoji":"♌","aliases":["leo"]},{"emoji":"♍","aliases":["virgo"]},{"emoji":"♎","aliases":["libra"]},{"emoji":"♏","aliases":["scorpius"]},{"emoji":"♐","aliases":["sagittarius"]},{"emoji":"♑","aliases":["capricorn"]},{"emoji":"♒","aliases":["aquarius"]},{"emoji":"♓","aliases":["pisces"]},{"emoji":"⛎","aliases":["ophiuchus"]},{"emoji":"🔀","aliases":["twisted_rightwards_arrows"]},{"emoji":"🔁","aliases":["repeat"]},{"emoji":"🔂","aliases":["repeat_one"]},{"emoji":"▶️","aliases":["arrow_forward"]},{"emoji":"⏩","aliases":["fast_forward"]},{"emoji":"⏭️","aliases":["next_track_button"]},{"emoji":"⏯️","aliases":["play_or_pause_button"]},{"emoji":"◀️","aliases":["arrow_backward"]},{"emoji":"⏪","aliases":["rewind"]},{"emoji":"⏮️","aliases":["previous_track_button"]},{"emoji":"🔼","aliases":["arrow_up_small"]},{"emoji":"⏫","aliases":["arrow_double_up"]},{"emoji":"🔽","aliases":["arrow_down_small"]},{"emoji":"⏬","aliases":["arrow_double_down"]},{"emoji":"⏸️","aliases":["pause_button"]},{"emoji":"⏹️","aliases":["stop_button"]},{"emoji":"⏺️","aliases":["record_button"]},{"emoji":"⏏️","aliases":["eject_button"]},{"emoji":"🎦","aliases":["cinema"]},{"emoji":"🔅","aliases":["low_brightness"]},{"emoji":"🔆","aliases":["high_brightness"]},{"emoji":"📶","aliases":["signal_strength"]},{"emoji":"📳","aliases":["vibration_mode"]},{"emoji":"📴","aliases":["mobile_phone_off"]},{"emoji":"♀️","aliases":["female_sign"]},{"emoji":"♂️","aliases":["male_sign"]},{"emoji":"⚧️","aliases":["transgender_symbol"]},{"emoji":"✖️","aliases":["heavy_multiplication_x"]},{"emoji":"➕","aliases":["heavy_plus_sign"]},{"emoji":"➖","aliases":["heavy_minus_sign"]},{"emoji":"➗","aliases":["heavy_division_sign"]},{"emoji":"♾️","aliases":["infinity"]},{"emoji":"‼️","aliases":["bangbang"]},{"emoji":"⁉️","aliases":["interrobang"]},{"emoji":"❓","aliases":["question"]},{"emoji":"❔","aliases":["grey_question"]},{"emoji":"❕","aliases":["grey_exclamation"]},{"emoji":"❗","aliases":["exclamation","heavy_exclamation_mark"]},{"emoji":"〰️","aliases":["wavy_dash"]},{"emoji":"💱","aliases":["currency_exchange"]},{"emoji":"💲","aliases":["heavy_dollar_sign"]},{"emoji":"⚕️","aliases":["medical_symbol"]},{"emoji":"♻️","aliases":["recycle"]},{"emoji":"⚜️","aliases":["fleur_de_lis"]},{"emoji":"🔱","aliases":["trident"]},{"emoji":"📛","aliases":["name_badge"]},{"emoji":"🔰","aliases":["beginner"]},{"emoji":"⭕","aliases":["o"]},{"emoji":"✅","aliases":["white_check_mark"]},{"emoji":"☑️","aliases":["ballot_box_with_check"]},{"emoji":"✔️","aliases":["heavy_check_mark"]},{"emoji":"❌","aliases":["x"]},{"emoji":"❎","aliases":["negative_squared_cross_mark"]},{"emoji":"➰","aliases":["curly_loop"]},{"emoji":"➿","aliases":["loop"]},{"emoji":"〽️","aliases":["part_alternation_mark"]},{"emoji":"✳️","aliases":["eight_spoked_asterisk"]},{"emoji":"✴️","aliases":["eight_pointed_black_star"]},{"emoji":"❇️","aliases":["sparkle"]},{"emoji":"©️","aliases":["copyright"]},{"emoji":"®️","aliases":["registered"]},{"emoji":"™️","aliases":["tm"]},{"emoji":"#️⃣","aliases":["hash"]},{"emoji":"*️⃣","aliases":["asterisk"]},{"emoji":"0️⃣","aliases":["zero"]},{"emoji":"1️⃣","aliases":["one"]},{"emoji":"2️⃣","aliases":["two"]},{"emoji":"3️⃣","aliases":["three"]},{"emoji":"4️⃣","aliases":["four"]},{"emoji":"5️⃣","aliases":["five"]},{"emoji":"6️⃣","aliases":["six"]},{"emoji":"7️⃣","aliases":["seven"]},{"emoji":"8️⃣","aliases":["eight"]},{"emoji":"9️⃣","aliases":["nine"]},{"emoji":"🔟","aliases":["keycap_ten"]},{"emoji":"🔠","aliases":["capital_abcd"]},{"emoji":"🔡","aliases":["abcd"]},{"emoji":"🔢","aliases":["1234"]},{"emoji":"🔣","aliases":["symbols"]},{"emoji":"🔤","aliases":["abc"]},{"emoji":"🅰️","aliases":["a"]},{"emoji":"🆎","aliases":["ab"]},{"emoji":"🅱️","aliases":["b"]},{"emoji":"🆑","aliases":["cl"]},{"emoji":"🆒","aliases":["cool"]},{"emoji":"🆓","aliases":["free"]},{"emoji":"ℹ️","aliases":["information_source"]},{"emoji":"🆔","aliases":["id"]},{"emoji":"Ⓜ️","aliases":["m"]},{"emoji":"🆕","aliases":["new"]},{"emoji":"🆖","aliases":["ng"]},{"emoji":"🅾️","aliases":["o2"]},{"emoji":"🆗","aliases":["ok"]},{"emoji":"🅿️","aliases":["parking"]},{"emoji":"🆘","aliases":["sos"]},{"emoji":"🆙","aliases":["up"]},{"emoji":"🆚","aliases":["vs"]},{"emoji":"🈁","aliases":["koko"]},{"emoji":"🈂️","aliases":["sa"]},{"emoji":"🈷️","aliases":["u6708"]},{"emoji":"🈶","aliases":["u6709"]},{"emoji":"🈯","aliases":["u6307"]},{"emoji":"🉐","aliases":["ideograph_advantage"]},{"emoji":"🈹","aliases":["u5272"]},{"emoji":"🈚","aliases":["u7121"]},{"emoji":"🈲","aliases":["u7981"]},{"emoji":"🉑","aliases":["accept"]},{"emoji":"🈸","aliases":["u7533"]},{"emoji":"🈴","aliases":["u5408"]},{"emoji":"🈳","aliases":["u7a7a"]},{"emoji":"㊗️","aliases":["congratulations"]},{"emoji":"㊙️","aliases":["secret"]},{"emoji":"🈺","aliases":["u55b6"]},{"emoji":"🈵","aliases":["u6e80"]},{"emoji":"🔴","aliases":["red_circle"]},{"emoji":"🟠","aliases":["orange_circle"]},{"emoji":"🟡","aliases":["yellow_circle"]},{"emoji":"🟢","aliases":["green_circle"]},{"emoji":"🔵","aliases":["large_blue_circle"]},{"emoji":"🟣","aliases":["purple_circle"]},{"emoji":"🟤","aliases":["brown_circle"]},{"emoji":"⚫","aliases":["black_circle"]},{"emoji":"⚪","aliases":["white_circle"]},{"emoji":"🟥","aliases":["red_square"]},{"emoji":"🟧","aliases":["orange_square"]},{"emoji":"🟨","aliases":["yellow_square"]},{"emoji":"🟩","aliases":["green_square"]},{"emoji":"🟦","aliases":["blue_square"]},{"emoji":"🟪","aliases":["purple_square"]},{"emoji":"🟫","aliases":["brown_square"]},{"emoji":"⬛","aliases":["black_large_square"]},{"emoji":"⬜","aliases":["white_large_square"]},{"emoji":"◼️","aliases":["black_medium_square"]},{"emoji":"◻️","aliases":["white_medium_square"]},{"emoji":"◾","aliases":["black_medium_small_square"]},{"emoji":"◽","aliases":["white_medium_small_square"]},{"emoji":"▪️","aliases":["black_small_square"]},{"emoji":"▫️","aliases":["white_small_square"]},{"emoji":"🔶","aliases":["large_orange_diamond"]},{"emoji":"🔷","aliases":["large_blue_diamond"]},{"emoji":"🔸","aliases":["small_orange_diamond"]},{"emoji":"🔹","aliases":["small_blue_diamond"]},{"emoji":"🔺","aliases":["small_red_triangle"]},{"emoji":"🔻","aliases":["small_red_triangle_down"]},{"emoji":"💠","aliases":["diamond_shape_with_a_dot_inside"]},{"emoji":"🔘","aliases":["radio_button"]},{"emoji":"🔳","aliases":["white_square_button"]},{"emoji":"🔲","aliases":["black_square_button"]},{"emoji":"🏁","aliases":["checkered_flag"]},{"emoji":"🚩","aliases":["triangular_flag_on_post"]},{"emoji":"🎌","aliases":["crossed_flags"]},{"emoji":"🏴","aliases":["black_flag"]},{"emoji":"🏳️","aliases":["white_flag"]},{"emoji":"🏳️‍🌈","aliases":["rainbow_flag"]},{"emoji":"🏳️‍⚧️","aliases":["transgender_flag"]},{"emoji":"🏴‍☠️","aliases":["pirate_flag"]},{"emoji":"🇦🇨","aliases":["ascension_island"]},{"emoji":"🇦🇩","aliases":["andorra"]},{"emoji":"🇦🇪","aliases":["united_arab_emirates"]},{"emoji":"🇦🇫","aliases":["afghanistan"]},{"emoji":"🇦🇬","aliases":["antigua_barbuda"]},{"emoji":"🇦🇮","aliases":["anguilla"]},{"emoji":"🇦🇱","aliases":["albania"]},{"emoji":"🇦🇲","aliases":["armenia"]},{"emoji":"🇦🇴","aliases":["angola"]},{"emoji":"🇦🇶","aliases":["antarctica"]},{"emoji":"🇦🇷","aliases":["argentina"]},{"emoji":"🇦🇸","aliases":["american_samoa"]},{"emoji":"🇦🇹","aliases":["austria"]},{"emoji":"🇦🇺","aliases":["australia"]},{"emoji":"🇦🇼","aliases":["aruba"]},{"emoji":"🇦🇽","aliases":["aland_islands"]},{"emoji":"🇦🇿","aliases":["azerbaijan"]},{"emoji":"🇧🇦","aliases":["bosnia_herzegovina"]},{"emoji":"🇧🇧","aliases":["barbados"]},{"emoji":"🇧🇩","aliases":["bangladesh"]},{"emoji":"🇧🇪","aliases":["belgium"]},{"emoji":"🇧🇫","aliases":["burkina_faso"]},{"emoji":"🇧🇬","aliases":["bulgaria"]},{"emoji":"🇧🇭","aliases":["bahrain"]},{"emoji":"🇧🇮","aliases":["burundi"]},{"emoji":"🇧🇯","aliases":["benin"]},{"emoji":"🇧🇱","aliases":["st_barthelemy"]},{"emoji":"🇧🇲","aliases":["bermuda"]},{"emoji":"🇧🇳","aliases":["brunei"]},{"emoji":"🇧🇴","aliases":["bolivia"]},{"emoji":"🇧🇶","aliases":["caribbean_netherlands"]},{"emoji":"🇧🇷","aliases":["brazil"]},{"emoji":"🇧🇸","aliases":["bahamas"]},{"emoji":"🇧🇹","aliases":["bhutan"]},{"emoji":"🇧🇻","aliases":["bouvet_island"]},{"emoji":"🇧🇼","aliases":["botswana"]},{"emoji":"🇧🇾","aliases":["belarus"]},{"emoji":"🇧🇿","aliases":["belize"]},{"emoji":"🇨🇦","aliases":["canada"]},{"emoji":"🇨🇨","aliases":["cocos_islands"]},{"emoji":"🇨🇩","aliases":["congo_kinshasa"]},{"emoji":"🇨🇫","aliases":["central_african_republic"]},{"emoji":"🇨🇬","aliases":["congo_brazzaville"]},{"emoji":"🇨🇭","aliases":["switzerland"]},{"emoji":"🇨🇮","aliases":["cote_divoire"]},{"emoji":"🇨🇰","aliases":["cook_islands"]},{"emoji":"🇨🇱","aliases":["chile"]},{"emoji":"🇨🇲","aliases":["cameroon"]},{"emoji":"🇨🇳","aliases":["cn"]},{"emoji":"🇨🇴","aliases":["colombia"]},{"emoji":"🇨🇵","aliases":["clipperton_island"]},{"emoji":"🇨🇷","aliases":["costa_rica"]},{"emoji":"🇨🇺","aliases":["cuba"]},{"emoji":"🇨🇻","aliases":["cape_verde"]},{"emoji":"🇨🇼","aliases":["curacao"]},{"emoji":"🇨🇽","aliases":["christmas_island"]},{"emoji":"🇨🇾","aliases":["cyprus"]},{"emoji":"🇨🇿","aliases":["czech_republic"]},{"emoji":"🇩🇪","aliases":["de"]},{"emoji":"🇩🇬","aliases":["diego_garcia"]},{"emoji":"🇩🇯","aliases":["djibouti"]},{"emoji":"🇩🇰","aliases":["denmark"]},{"emoji":"🇩🇲","aliases":["dominica"]},{"emoji":"🇩🇴","aliases":["dominican_republic"]},{"emoji":"🇩🇿","aliases":["algeria"]},{"emoji":"🇪🇦","aliases":["ceuta_melilla"]},{"emoji":"🇪🇨","aliases":["ecuador"]},{"emoji":"🇪🇪","aliases":["estonia"]},{"emoji":"🇪🇬","aliases":["egypt"]},{"emoji":"🇪🇭","aliases":["western_sahara"]},{"emoji":"🇪🇷","aliases":["eritrea"]},{"emoji":"🇪🇸","aliases":["es"]},{"emoji":"🇪🇹","aliases":["ethiopia"]},{"emoji":"🇪🇺","aliases":["eu","european_union"]},{"emoji":"🇫🇮","aliases":["finland"]},{"emoji":"🇫🇯","aliases":["fiji"]},{"emoji":"🇫🇰","aliases":["falkland_islands"]},{"emoji":"🇫🇲","aliases":["micronesia"]},{"emoji":"🇫🇴","aliases":["faroe_islands"]},{"emoji":"🇫🇷","aliases":["fr"]},{"emoji":"🇬🇦","aliases":["gabon"]},{"emoji":"🇬🇧","aliases":["gb","uk"]},{"emoji":"🇬🇩","aliases":["grenada"]},{"emoji":"🇬🇪","aliases":["georgia"]},{"emoji":"🇬🇫","aliases":["french_guiana"]},{"emoji":"🇬🇬","aliases":["guernsey"]},{"emoji":"🇬🇭","aliases":["ghana"]},{"emoji":"🇬🇮","aliases":["gibraltar"]},{"emoji":"🇬🇱","aliases":["greenland"]},{"emoji":"🇬🇲","aliases":["gambia"]},{"emoji":"🇬🇳","aliases":["guinea"]},{"emoji":"🇬🇵","aliases":["guadeloupe"]},{"emoji":"🇬🇶","aliases":["equatorial_guinea"]},{"emoji":"🇬🇷","aliases":["greece"]},{"emoji":"🇬🇸","aliases":["south_georgia_south_sandwich_islands"]},{"emoji":"🇬🇹","aliases":["guatemala"]},{"emoji":"🇬🇺","aliases":["guam"]},{"emoji":"🇬🇼","aliases":["guinea_bissau"]},{"emoji":"🇬🇾","aliases":["guyana"]},{"emoji":"🇭🇰","aliases":["hong_kong"]},{"emoji":"🇭🇲","aliases":["heard_mcdonald_islands"]},{"emoji":"🇭🇳","aliases":["honduras"]},{"emoji":"🇭🇷","aliases":["croatia"]},{"emoji":"🇭🇹","aliases":["haiti"]},{"emoji":"🇭🇺","aliases":["hungary"]},{"emoji":"🇮🇨","aliases":["canary_islands"]},{"emoji":"🇮🇩","aliases":["indonesia"]},{"emoji":"🇮🇪","aliases":["ireland"]},{"emoji":"🇮🇱","aliases":["israel"]},{"emoji":"🇮🇲","aliases":["isle_of_man"]},{"emoji":"🇮🇳","aliases":["india"]},{"emoji":"🇮🇴","aliases":["british_indian_ocean_territory"]},{"emoji":"🇮🇶","aliases":["iraq"]},{"emoji":"🇮🇷","aliases":["iran"]},{"emoji":"🇮🇸","aliases":["iceland"]},{"emoji":"🇮🇹","aliases":["it"]},{"emoji":"🇯🇪","aliases":["jersey"]},{"emoji":"🇯🇲","aliases":["jamaica"]},{"emoji":"🇯🇴","aliases":["jordan"]},{"emoji":"🇯🇵","aliases":["jp"]},{"emoji":"🇰🇪","aliases":["kenya"]},{"emoji":"🇰🇬","aliases":["kyrgyzstan"]},{"emoji":"🇰🇭","aliases":["cambodia"]},{"emoji":"🇰🇮","aliases":["kiribati"]},{"emoji":"🇰🇲","aliases":["comoros"]},{"emoji":"🇰🇳","aliases":["st_kitts_nevis"]},{"emoji":"🇰🇵","aliases":["north_korea"]},{"emoji":"🇰🇷","aliases":["kr"]},{"emoji":"🇰🇼","aliases":["kuwait"]},{"emoji":"🇰🇾","aliases":["cayman_islands"]},{"emoji":"🇰🇿","aliases":["kazakhstan"]},{"emoji":"🇱🇦","aliases":["laos"]},{"emoji":"🇱🇧","aliases":["lebanon"]},{"emoji":"🇱🇨","aliases":["st_lucia"]},{"emoji":"🇱🇮","aliases":["liechtenstein"]},{"emoji":"🇱🇰","aliases":["sri_lanka"]},{"emoji":"🇱🇷","aliases":["liberia"]},{"emoji":"🇱🇸","aliases":["lesotho"]},{"emoji":"🇱🇹","aliases":["lithuania"]},{"emoji":"🇱🇺","aliases":["luxembourg"]},{"emoji":"🇱🇻","aliases":["latvia"]},{"emoji":"🇱🇾","aliases":["libya"]},{"emoji":"🇲🇦","aliases":["morocco"]},{"emoji":"🇲🇨","aliases":["monaco"]},{"emoji":"🇲🇩","aliases":["moldova"]},{"emoji":"🇲🇪","aliases":["montenegro"]},{"emoji":"🇲🇫","aliases":["st_martin"]},{"emoji":"🇲🇬","aliases":["madagascar"]},{"emoji":"🇲🇭","aliases":["marshall_islands"]},{"emoji":"🇲🇰","aliases":["macedonia"]},{"emoji":"🇲🇱","aliases":["mali"]},{"emoji":"🇲🇲","aliases":["myanmar"]},{"emoji":"🇲🇳","aliases":["mongolia"]},{"emoji":"🇲🇴","aliases":["macau"]},{"emoji":"🇲🇵","aliases":["northern_mariana_islands"]},{"emoji":"🇲🇶","aliases":["martinique"]},{"emoji":"🇲🇷","aliases":["mauritania"]},{"emoji":"🇲🇸","aliases":["montserrat"]},{"emoji":"🇲🇹","aliases":["malta"]},{"emoji":"🇲🇺","aliases":["mauritius"]},{"emoji":"🇲🇻","aliases":["maldives"]},{"emoji":"🇲🇼","aliases":["malawi"]},{"emoji":"🇲🇽","aliases":["mexico"]},{"emoji":"🇲🇾","aliases":["malaysia"]},{"emoji":"🇲🇿","aliases":["mozambique"]},{"emoji":"🇳🇦","aliases":["namibia"]},{"emoji":"🇳🇨","aliases":["new_caledonia"]},{"emoji":"🇳🇪","aliases":["niger"]},{"emoji":"🇳🇫","aliases":["norfolk_island"]},{"emoji":"🇳🇬","aliases":["nigeria"]},{"emoji":"🇳🇮","aliases":["nicaragua"]},{"emoji":"🇳🇱","aliases":["netherlands"]},{"emoji":"🇳🇴","aliases":["norway"]},{"emoji":"🇳🇵","aliases":["nepal"]},{"emoji":"🇳🇷","aliases":["nauru"]},{"emoji":"🇳🇺","aliases":["niue"]},{"emoji":"🇳🇿","aliases":["new_zealand"]},{"emoji":"🇴🇲","aliases":["oman"]},{"emoji":"🇵🇦","aliases":["panama"]},{"emoji":"🇵🇪","aliases":["peru"]},{"emoji":"🇵🇫","aliases":["french_polynesia"]},{"emoji":"🇵🇬","aliases":["papua_new_guinea"]},{"emoji":"🇵🇭","aliases":["philippines"]},{"emoji":"🇵🇰","aliases":["pakistan"]},{"emoji":"🇵🇱","aliases":["poland"]},{"emoji":"🇵🇲","aliases":["st_pierre_miquelon"]},{"emoji":"🇵🇳","aliases":["pitcairn_islands"]},{"emoji":"🇵🇷","aliases":["puerto_rico"]},{"emoji":"🇵🇸","aliases":["palestinian_territories"]},{"emoji":"🇵🇹","aliases":["portugal"]},{"emoji":"🇵🇼","aliases":["palau"]},{"emoji":"🇵🇾","aliases":["paraguay"]},{"emoji":"🇶🇦","aliases":["qatar"]},{"emoji":"🇷🇪","aliases":["reunion"]},{"emoji":"🇷🇴","aliases":["romania"]},{"emoji":"🇷🇸","aliases":["serbia"]},{"emoji":"🇷🇺","aliases":["ru"]},{"emoji":"🇷🇼","aliases":["rwanda"]},{"emoji":"🇸🇦","aliases":["saudi_arabia"]},{"emoji":"🇸🇧","aliases":["solomon_islands"]},{"emoji":"🇸🇨","aliases":["seychelles"]},{"emoji":"🇸🇩","aliases":["sudan"]},{"emoji":"🇸🇪","aliases":["sweden"]},{"emoji":"🇸🇬","aliases":["singapore"]},{"emoji":"🇸🇭","aliases":["st_helena"]},{"emoji":"🇸🇮","aliases":["slovenia"]},{"emoji":"🇸🇯","aliases":["svalbard_jan_mayen"]},{"emoji":"🇸🇰","aliases":["slovakia"]},{"emoji":"🇸🇱","aliases":["sierra_leone"]},{"emoji":"🇸🇲","aliases":["san_marino"]},{"emoji":"🇸🇳","aliases":["senegal"]},{"emoji":"🇸🇴","aliases":["somalia"]},{"emoji":"🇸🇷","aliases":["suriname"]},{"emoji":"🇸🇸","aliases":["south_sudan"]},{"emoji":"🇸🇹","aliases":["sao_tome_principe"]},{"emoji":"🇸🇻","aliases":["el_salvador"]},{"emoji":"🇸🇽","aliases":["sint_maarten"]},{"emoji":"🇸🇾","aliases":["syria"]},{"emoji":"🇸🇿","aliases":["swaziland"]},{"emoji":"🇹🇦","aliases":["tristan_da_cunha"]},{"emoji":"🇹🇨","aliases":["turks_caicos_islands"]},{"emoji":"🇹🇩","aliases":["chad"]},{"emoji":"🇹🇫","aliases":["french_southern_territories"]},{"emoji":"🇹🇬","aliases":["togo"]},{"emoji":"🇹🇭","aliases":["thailand"]},{"emoji":"🇹🇯","aliases":["tajikistan"]},{"emoji":"🇹🇰","aliases":["tokelau"]},{"emoji":"🇹🇱","aliases":["timor_leste"]},{"emoji":"🇹🇲","aliases":["turkmenistan"]},{"emoji":"🇹🇳","aliases":["tunisia"]},{"emoji":"🇹🇴","aliases":["tonga"]},{"emoji":"🇹🇷","aliases":["tr"]},{"emoji":"🇹🇹","aliases":["trinidad_tobago"]},{"emoji":"🇹🇻","aliases":["tuvalu"]},{"emoji":"🇹🇼","aliases":["taiwan"]},{"emoji":"🇹🇿","aliases":["tanzania"]},{"emoji":"🇺🇦","aliases":["ukraine"]},{"emoji":"🇺🇬","aliases":["uganda"]},{"emoji":"🇺🇲","aliases":["us_outlying_islands"]},{"emoji":"🇺🇳","aliases":["united_nations"]},{"emoji":"🇺🇸","aliases":["us"]},{"emoji":"🇺🇾","aliases":["uruguay"]},{"emoji":"🇺🇿","aliases":["uzbekistan"]},{"emoji":"🇻🇦","aliases":["vatican_city"]},{"emoji":"🇻🇨","aliases":["st_vincent_grenadines"]},{"emoji":"🇻🇪","aliases":["venezuela"]},{"emoji":"🇻🇬","aliases":["british_virgin_islands"]},{"emoji":"🇻🇮","aliases":["us_virgin_islands"]},{"emoji":"🇻🇳","aliases":["vietnam"]},{"emoji":"🇻🇺","aliases":["vanuatu"]},{"emoji":"🇼🇫","aliases":["wallis_futuna"]},{"emoji":"🇼🇸","aliases":["samoa"]},{"emoji":"🇽🇰","aliases":["kosovo"]},{"emoji":"🇾🇪","aliases":["yemen"]},{"emoji":"🇾🇹","aliases":["mayotte"]},{"emoji":"🇿🇦","aliases":["south_africa"]},{"emoji":"🇿🇲","aliases":["zambia"]},{"emoji":"🇿🇼","aliases":["zimbabwe"]},{"emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","aliases":["england"]},{"emoji":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","aliases":["scotland"]},{"emoji":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","aliases":["wales"]}] diff --git a/server/mailer_emoji_map.json b/server/mailer_emoji_map.json deleted file mode 100644 index 8520c24..0000000 --- a/server/mailer_emoji_map.json +++ /dev/null @@ -1,1857 +0,0 @@ -{ - "+1": "👍", - "-1": "👎", - "100": "💯", - "1234": "🔢", - "1st_place_medal": "🥇", - "2nd_place_medal": "🥈", - "3rd_place_medal": "🥉", - "8ball": "🎱", - "a": "🅰️", - "ab": "🆎", - "abacus": "🧮", - "abc": "🔤", - "abcd": "🔡", - "accept": "🉑", - "accordion": "🪗", - "adhesive_bandage": "🩹", - "adult": "🧑", - "aerial_tramway": "🚡", - "afghanistan": "🇦🇫", - "airplane": "✈️", - "aland_islands": "🇦🇽", - "alarm_clock": "⏰", - "albania": "🇦🇱", - "alembic": "⚗️", - "algeria": "🇩🇿", - "alien": "👽", - "ambulance": "🚑", - "american_samoa": "🇦🇸", - "amphora": "🏺", - "anatomical_heart": "🫀", - "anchor": "⚓", - "andorra": "🇦🇩", - "angel": "👼", - "anger": "💢", - "angola": "🇦🇴", - "angry": "😠", - "anguilla": "🇦🇮", - "anguished": "😧", - "ant": "🐜", - "antarctica": "🇦🇶", - "antigua_barbuda": "🇦🇬", - "apple": "🍎", - "aquarius": "♒", - "argentina": "🇦🇷", - "aries": "♈", - "armenia": "🇦🇲", - "arrow_backward": "◀️", - "arrow_double_down": "⏬", - "arrow_double_up": "⏫", - "arrow_down": "⬇️", - "arrow_down_small": "🔽", - "arrow_forward": "▶️", - "arrow_heading_down": "⤵️", - "arrow_heading_up": "⤴️", - "arrow_left": "⬅️", - "arrow_lower_left": "↙️", - "arrow_lower_right": "↘️", - "arrow_right": "➡️", - "arrow_right_hook": "↪️", - "arrow_up": "⬆️", - "arrow_up_down": "↕️", - "arrow_up_small": "🔼", - "arrow_upper_left": "↖️", - "arrow_upper_right": "↗️", - "arrows_clockwise": "🔃", - "arrows_counterclockwise": "🔄", - "art": "🎨", - "articulated_lorry": "🚛", - "artificial_satellite": "🛰️", - "artist": "🧑‍🎨", - "aruba": "🇦🇼", - "ascension_island": "🇦🇨", - "asterisk": "*️⃣", - "astonished": "😲", - "astronaut": "🧑‍🚀", - "athletic_shoe": "👟", - "atm": "🏧", - "atom_symbol": "⚛️", - "australia": "🇦🇺", - "austria": "🇦🇹", - "auto_rickshaw": "🛺", - "avocado": "🥑", - "axe": "🪓", - "azerbaijan": "🇦🇿", - "b": "🅱️", - "baby": "👶", - "baby_bottle": "🍼", - "baby_chick": "🐤", - "baby_symbol": "🚼", - "back": "🔙", - "bacon": "🥓", - "badger": "🦡", - "badminton": "🏸", - "bagel": "🥯", - "baggage_claim": "🛄", - "baguette_bread": "🥖", - "bahamas": "🇧🇸", - "bahrain": "🇧🇭", - "balance_scale": "⚖️", - "bald_man": "👨‍🦲", - "bald_woman": "👩‍🦲", - "ballet_shoes": "🩰", - "balloon": "🎈", - "ballot_box": "🗳️", - "ballot_box_with_check": "☑️", - "bamboo": "🎍", - "banana": "🍌", - "bangbang": "‼️", - "bangladesh": "🇧🇩", - "banjo": "🪕", - "bank": "🏦", - "bar_chart": "📊", - "barbados": "🇧🇧", - "barber": "💈", - "baseball": "⚾", - "basket": "🧺", - "basketball": "🏀", - "basketball_man": "⛹️‍♂️", - "basketball_woman": "⛹️‍♀️", - "bat": "🦇", - "bath": "🛀", - "bathtub": "🛁", - "battery": "🔋", - "beach_umbrella": "🏖️", - "bear": "🐻", - "bearded_person": "🧔", - "beaver": "🦫", - "bed": "🛏️", - "bee": "🐝", - "beer": "🍺", - "beers": "🍻", - "beetle": "🪲", - "beginner": "🔰", - "belarus": "🇧🇾", - "belgium": "🇧🇪", - "belize": "🇧🇿", - "bell": "🔔", - "bell_pepper": "🫑", - "bellhop_bell": "🛎️", - "benin": "🇧🇯", - "bento": "🍱", - "bermuda": "🇧🇲", - "beverage_box": "🧃", - "bhutan": "🇧🇹", - "bicyclist": "🚴", - "bike": "🚲", - "biking_man": "🚴‍♂️", - "biking_woman": "🚴‍♀️", - "bikini": "👙", - "billed_cap": "🧢", - "biohazard": "☣️", - "bird": "🐦", - "birthday": "🎂", - "bison": "🦬", - "black_cat": "🐈‍⬛", - "black_circle": "⚫", - "black_flag": "🏴", - "black_heart": "🖤", - "black_joker": "🃏", - "black_large_square": "⬛", - "black_medium_small_square": "◾", - "black_medium_square": "◼️", - "black_nib": "✒️", - "black_small_square": "▪️", - "black_square_button": "🔲", - "blond_haired_man": "👱‍♂️", - "blond_haired_person": "👱", - "blond_haired_woman": "👱‍♀️", - "blonde_woman": "👱‍♀️", - "blossom": "🌼", - "blowfish": "🐡", - "blue_book": "📘", - "blue_car": "🚙", - "blue_heart": "💙", - "blue_square": "🟦", - "blueberries": "🫐", - "blush": "😊", - "boar": "🐗", - "boat": "⛵", - "bolivia": "🇧🇴", - "bomb": "💣", - "bone": "🦴", - "book": "📖", - "bookmark": "🔖", - "bookmark_tabs": "📑", - "books": "📚", - "boom": "💥", - "boomerang": "🪃", - "boot": "👢", - "bosnia_herzegovina": "🇧🇦", - "botswana": "🇧🇼", - "bouncing_ball_man": "⛹️‍♂️", - "bouncing_ball_person": "⛹️", - "bouncing_ball_woman": "⛹️‍♀️", - "bouquet": "💐", - "bouvet_island": "🇧🇻", - "bow": "🙇", - "bow_and_arrow": "🏹", - "bowing_man": "🙇‍♂️", - "bowing_woman": "🙇‍♀️", - "bowl_with_spoon": "🥣", - "bowling": "🎳", - "boxing_glove": "🥊", - "boy": "👦", - "brain": "🧠", - "brazil": "🇧🇷", - "bread": "🍞", - "breast_feeding": "🤱", - "bricks": "🧱", - "bride_with_veil": "👰‍♀️", - "bridge_at_night": "🌉", - "briefcase": "💼", - "british_indian_ocean_territory": "🇮🇴", - "british_virgin_islands": "🇻🇬", - "broccoli": "🥦", - "broken_heart": "💔", - "broom": "🧹", - "brown_circle": "🟤", - "brown_heart": "🤎", - "brown_square": "🟫", - "brunei": "🇧🇳", - "bubble_tea": "🧋", - "bucket": "🪣", - "bug": "🐛", - "building_construction": "🏗️", - "bulb": "💡", - "bulgaria": "🇧🇬", - "bullettrain_front": "🚅", - "bullettrain_side": "🚄", - "burkina_faso": "🇧🇫", - "burrito": "🌯", - "burundi": "🇧🇮", - "bus": "🚌", - "business_suit_levitating": "🕴️", - "busstop": "🚏", - "bust_in_silhouette": "👤", - "busts_in_silhouette": "👥", - "butter": "🧈", - "butterfly": "🦋", - "cactus": "🌵", - "cake": "🍰", - "calendar": "📆", - "call_me_hand": "🤙", - "calling": "📲", - "cambodia": "🇰🇭", - "camel": "🐫", - "camera": "📷", - "camera_flash": "📸", - "cameroon": "🇨🇲", - "camping": "🏕️", - "canada": "🇨🇦", - "canary_islands": "🇮🇨", - "cancer": "♋", - "candle": "🕯️", - "candy": "🍬", - "canned_food": "🥫", - "canoe": "🛶", - "cape_verde": "🇨🇻", - "capital_abcd": "🔠", - "capricorn": "♑", - "car": "🚗", - "card_file_box": "🗃️", - "card_index": "📇", - "card_index_dividers": "🗂️", - "caribbean_netherlands": "🇧🇶", - "carousel_horse": "🎠", - "carpentry_saw": "🪚", - "carrot": "🥕", - "cartwheeling": "🤸", - "cat": "🐱", - "cat2": "🐈", - "cayman_islands": "🇰🇾", - "cd": "💿", - "central_african_republic": "🇨🇫", - "ceuta_melilla": "🇪🇦", - "chad": "🇹🇩", - "chains": "⛓️", - "chair": "🪑", - "champagne": "🍾", - "chart": "💹", - "chart_with_downwards_trend": "📉", - "chart_with_upwards_trend": "📈", - "checkered_flag": "🏁", - "cheese": "🧀", - "cherries": "🍒", - "cherry_blossom": "🌸", - "chess_pawn": "♟️", - "chestnut": "🌰", - "chicken": "🐔", - "child": "🧒", - "children_crossing": "🚸", - "chile": "🇨🇱", - "chipmunk": "🐿️", - "chocolate_bar": "🍫", - "chopsticks": "🥢", - "christmas_island": "🇨🇽", - "christmas_tree": "🎄", - "church": "⛪", - "cinema": "🎦", - "circus_tent": "🎪", - "city_sunrise": "🌇", - "city_sunset": "🌆", - "cityscape": "🏙️", - "cl": "🆑", - "clamp": "🗜️", - "clap": "👏", - "clapper": "🎬", - "classical_building": "🏛️", - "climbing": "🧗", - "climbing_man": "🧗‍♂️", - "climbing_woman": "🧗‍♀️", - "clinking_glasses": "🥂", - "clipboard": "📋", - "clipperton_island": "🇨🇵", - "clock1": "🕐", - "clock10": "🕙", - "clock1030": "🕥", - "clock11": "🕚", - "clock1130": "🕦", - "clock12": "🕛", - "clock1230": "🕧", - "clock130": "🕜", - "clock2": "🕑", - "clock230": "🕝", - "clock3": "🕒", - "clock330": "🕞", - "clock4": "🕓", - "clock430": "🕟", - "clock5": "🕔", - "clock530": "🕠", - "clock6": "🕕", - "clock630": "🕡", - "clock7": "🕖", - "clock730": "🕢", - "clock8": "🕗", - "clock830": "🕣", - "clock9": "🕘", - "clock930": "🕤", - "closed_book": "📕", - "closed_lock_with_key": "🔐", - "closed_umbrella": "🌂", - "cloud": "☁️", - "cloud_with_lightning": "🌩️", - "cloud_with_lightning_and_rain": "⛈️", - "cloud_with_rain": "🌧️", - "cloud_with_snow": "🌨️", - "clown_face": "🤡", - "clubs": "♣️", - "cn": "🇨🇳", - "coat": "🧥", - "cockroach": "🪳", - "cocktail": "🍸", - "coconut": "🥥", - "cocos_islands": "🇨🇨", - "coffee": "☕", - "coffin": "⚰️", - "coin": "🪙", - "cold_face": "🥶", - "cold_sweat": "😰", - "collision": "💥", - "colombia": "🇨🇴", - "comet": "☄️", - "comoros": "🇰🇲", - "compass": "🧭", - "computer": "💻", - "computer_mouse": "🖱️", - "confetti_ball": "🎊", - "confounded": "😖", - "confused": "😕", - "congo_brazzaville": "🇨🇬", - "congo_kinshasa": "🇨🇩", - "congratulations": "㊗️", - "construction": "🚧", - "construction_worker": "👷", - "construction_worker_man": "👷‍♂️", - "construction_worker_woman": "👷‍♀️", - "control_knobs": "🎛️", - "convenience_store": "🏪", - "cook": "🧑‍🍳", - "cook_islands": "🇨🇰", - "cookie": "🍪", - "cool": "🆒", - "cop": "👮", - "copyright": "©️", - "corn": "🌽", - "costa_rica": "🇨🇷", - "cote_divoire": "🇨🇮", - "couch_and_lamp": "🛋️", - "couple": "👫", - "couple_with_heart": "💑", - "couple_with_heart_man_man": "👨‍❤️‍👨", - "couple_with_heart_woman_man": "👩‍❤️‍👨", - "couple_with_heart_woman_woman": "👩‍❤️‍👩", - "couplekiss": "💏", - "couplekiss_man_man": "👨‍❤️‍💋‍👨", - "couplekiss_man_woman": "👩‍❤️‍💋‍👨", - "couplekiss_woman_woman": "👩‍❤️‍💋‍👩", - "cow": "🐮", - "cow2": "🐄", - "cowboy_hat_face": "🤠", - "crab": "🦀", - "crayon": "🖍️", - "credit_card": "💳", - "crescent_moon": "🌙", - "cricket": "🦗", - "cricket_game": "🏏", - "croatia": "🇭🇷", - "crocodile": "🐊", - "croissant": "🥐", - "crossed_fingers": "🤞", - "crossed_flags": "🎌", - "crossed_swords": "⚔️", - "crown": "👑", - "cry": "😢", - "crying_cat_face": "😿", - "crystal_ball": "🔮", - "cuba": "🇨🇺", - "cucumber": "🥒", - "cup_with_straw": "🥤", - "cupcake": "🧁", - "cupid": "💘", - "curacao": "🇨🇼", - "curling_stone": "🥌", - "curly_haired_man": "👨‍🦱", - "curly_haired_woman": "👩‍🦱", - "curly_loop": "➰", - "currency_exchange": "💱", - "curry": "🍛", - "cursing_face": "🤬", - "custard": "🍮", - "customs": "🛃", - "cut_of_meat": "🥩", - "cyclone": "🌀", - "cyprus": "🇨🇾", - "czech_republic": "🇨🇿", - "dagger": "🗡️", - "dancer": "💃", - "dancers": "👯", - "dancing_men": "👯‍♂️", - "dancing_women": "👯‍♀️", - "dango": "🍡", - "dark_sunglasses": "🕶️", - "dart": "🎯", - "dash": "💨", - "date": "📅", - "de": "🇩🇪", - "deaf_man": "🧏‍♂️", - "deaf_person": "🧏", - "deaf_woman": "🧏‍♀️", - "deciduous_tree": "🌳", - "deer": "🦌", - "denmark": "🇩🇰", - "department_store": "🏬", - "derelict_house": "🏚️", - "desert": "🏜️", - "desert_island": "🏝️", - "desktop_computer": "🖥️", - "detective": "🕵️", - "diamond_shape_with_a_dot_inside": "💠", - "diamonds": "♦️", - "diego_garcia": "🇩🇬", - "disappointed": "😞", - "disappointed_relieved": "😥", - "disguised_face": "🥸", - "diving_mask": "🤿", - "diya_lamp": "🪔", - "dizzy": "💫", - "dizzy_face": "😵", - "djibouti": "🇩🇯", - "dna": "🧬", - "do_not_litter": "🚯", - "dodo": "🦤", - "dog": "🐶", - "dog2": "🐕", - "dollar": "💵", - "dolls": "🎎", - "dolphin": "🐬", - "dominica": "🇩🇲", - "dominican_republic": "🇩🇴", - "door": "🚪", - "doughnut": "🍩", - "dove": "🕊️", - "dragon": "🐉", - "dragon_face": "🐲", - "dress": "👗", - "dromedary_camel": "🐪", - "drooling_face": "🤤", - "drop_of_blood": "🩸", - "droplet": "💧", - "drum": "🥁", - "duck": "🦆", - "dumpling": "🥟", - "dvd": "📀", - "e-mail": "📧", - "eagle": "🦅", - "ear": "👂", - "ear_of_rice": "🌾", - "ear_with_hearing_aid": "🦻", - "earth_africa": "🌍", - "earth_americas": "🌎", - "earth_asia": "🌏", - "ecuador": "🇪🇨", - "egg": "🥚", - "eggplant": "🍆", - "egypt": "🇪🇬", - "eight": "8️⃣", - "eight_pointed_black_star": "✴️", - "eight_spoked_asterisk": "✳️", - "eject_button": "⏏️", - "el_salvador": "🇸🇻", - "electric_plug": "🔌", - "elephant": "🐘", - "elevator": "🛗", - "elf": "🧝", - "elf_man": "🧝‍♂️", - "elf_woman": "🧝‍♀️", - "email": "📧", - "end": "🔚", - "england": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", - "envelope": "✉️", - "envelope_with_arrow": "📩", - "equatorial_guinea": "🇬🇶", - "eritrea": "🇪🇷", - "es": "🇪🇸", - "estonia": "🇪🇪", - "ethiopia": "🇪🇹", - "eu": "🇪🇺", - "euro": "💶", - "european_castle": "🏰", - "european_post_office": "🏤", - "european_union": "🇪🇺", - "evergreen_tree": "🌲", - "exclamation": "❗", - "exploding_head": "🤯", - "expressionless": "😑", - "eye": "👁️", - "eye_speech_bubble": "👁️‍🗨️", - "eyeglasses": "👓", - "eyes": "👀", - "face_exhaling": "😮‍💨", - "face_in_clouds": "😶‍🌫️", - "face_with_head_bandage": "🤕", - "face_with_spiral_eyes": "😵‍💫", - "face_with_thermometer": "🤒", - "facepalm": "🤦", - "facepunch": "👊", - "factory": "🏭", - "factory_worker": "🧑‍🏭", - "fairy": "🧚", - "fairy_man": "🧚‍♂️", - "fairy_woman": "🧚‍♀️", - "falafel": "🧆", - "falkland_islands": "🇫🇰", - "fallen_leaf": "🍂", - "family": "👪", - "family_man_boy": "👨‍👦", - "family_man_boy_boy": "👨‍👦‍👦", - "family_man_girl": "👨‍👧", - "family_man_girl_boy": "👨‍👧‍👦", - "family_man_girl_girl": "👨‍👧‍👧", - "family_man_man_boy": "👨‍👨‍👦", - "family_man_man_boy_boy": "👨‍👨‍👦‍👦", - "family_man_man_girl": "👨‍👨‍👧", - "family_man_man_girl_boy": "👨‍👨‍👧‍👦", - "family_man_man_girl_girl": "👨‍👨‍👧‍👧", - "family_man_woman_boy": "👨‍👩‍👦", - "family_man_woman_boy_boy": "👨‍👩‍👦‍👦", - "family_man_woman_girl": "👨‍👩‍👧", - "family_man_woman_girl_boy": "👨‍👩‍👧‍👦", - "family_man_woman_girl_girl": "👨‍👩‍👧‍👧", - "family_woman_boy": "👩‍👦", - "family_woman_boy_boy": "👩‍👦‍👦", - "family_woman_girl": "👩‍👧", - "family_woman_girl_boy": "👩‍👧‍👦", - "family_woman_girl_girl": "👩‍👧‍👧", - "family_woman_woman_boy": "👩‍👩‍👦", - "family_woman_woman_boy_boy": "👩‍👩‍👦‍👦", - "family_woman_woman_girl": "👩‍👩‍👧", - "family_woman_woman_girl_boy": "👩‍👩‍👧‍👦", - "family_woman_woman_girl_girl": "👩‍👩‍👧‍👧", - "farmer": "🧑‍🌾", - "faroe_islands": "🇫🇴", - "fast_forward": "⏩", - "fax": "📠", - "fearful": "😨", - "feather": "🪶", - "feet": "🐾", - "female_detective": "🕵️‍♀️", - "female_sign": "♀️", - "ferris_wheel": "🎡", - "ferry": "⛴️", - "field_hockey": "🏑", - "fiji": "🇫🇯", - "file_cabinet": "🗄️", - "file_folder": "📁", - "film_projector": "📽️", - "film_strip": "🎞️", - "finland": "🇫🇮", - "fire": "🔥", - "fire_engine": "🚒", - "fire_extinguisher": "🧯", - "firecracker": "🧨", - "firefighter": "🧑‍🚒", - "fireworks": "🎆", - "first_quarter_moon": "🌓", - "first_quarter_moon_with_face": "🌛", - "fish": "🐟", - "fish_cake": "🍥", - "fishing_pole_and_fish": "🎣", - "fist": "✊", - "fist_left": "🤛", - "fist_oncoming": "👊", - "fist_raised": "✊", - "fist_right": "🤜", - "five": "5️⃣", - "flags": "🎏", - "flamingo": "🦩", - "flashlight": "🔦", - "flat_shoe": "🥿", - "flatbread": "🫓", - "fleur_de_lis": "⚜️", - "flight_arrival": "🛬", - "flight_departure": "🛫", - "flipper": "🐬", - "floppy_disk": "💾", - "flower_playing_cards": "🎴", - "flushed": "😳", - "fly": "🪰", - "flying_disc": "🥏", - "flying_saucer": "🛸", - "fog": "🌫️", - "foggy": "🌁", - "fondue": "🫕", - "foot": "🦶", - "football": "🏈", - "footprints": "👣", - "fork_and_knife": "🍴", - "fortune_cookie": "🥠", - "fountain": "⛲", - "fountain_pen": "🖋️", - "four": "4️⃣", - "four_leaf_clover": "🍀", - "fox_face": "🦊", - "fr": "🇫🇷", - "framed_picture": "🖼️", - "free": "🆓", - "french_guiana": "🇬🇫", - "french_polynesia": "🇵🇫", - "french_southern_territories": "🇹🇫", - "fried_egg": "🍳", - "fried_shrimp": "🍤", - "fries": "🍟", - "frog": "🐸", - "frowning": "😦", - "frowning_face": "☹️", - "frowning_man": "🙍‍♂️", - "frowning_person": "🙍", - "frowning_woman": "🙍‍♀️", - "fu": "🖕", - "fuelpump": "⛽", - "full_moon": "🌕", - "full_moon_with_face": "🌝", - "funeral_urn": "⚱️", - "gabon": "🇬🇦", - "gambia": "🇬🇲", - "game_die": "🎲", - "garlic": "🧄", - "gb": "🇬🇧", - "gear": "⚙️", - "gem": "💎", - "gemini": "♊", - "genie": "🧞", - "genie_man": "🧞‍♂️", - "genie_woman": "🧞‍♀️", - "georgia": "🇬🇪", - "ghana": "🇬🇭", - "ghost": "👻", - "gibraltar": "🇬🇮", - "gift": "🎁", - "gift_heart": "💝", - "giraffe": "🦒", - "girl": "👧", - "globe_with_meridians": "🌐", - "gloves": "🧤", - "goal_net": "🥅", - "goat": "🐐", - "goggles": "🥽", - "golf": "⛳", - "golfing": "🏌️", - "golfing_man": "🏌️‍♂️", - "golfing_woman": "🏌️‍♀️", - "gorilla": "🦍", - "grapes": "🍇", - "greece": "🇬🇷", - "green_apple": "🍏", - "green_book": "📗", - "green_circle": "🟢", - "green_heart": "💚", - "green_salad": "🥗", - "green_square": "🟩", - "greenland": "🇬🇱", - "grenada": "🇬🇩", - "grey_exclamation": "❕", - "grey_question": "❔", - "grimacing": "😬", - "grin": "😁", - "grinning": "😀", - "guadeloupe": "🇬🇵", - "guam": "🇬🇺", - "guard": "💂", - "guardsman": "💂‍♂️", - "guardswoman": "💂‍♀️", - "guatemala": "🇬🇹", - "guernsey": "🇬🇬", - "guide_dog": "🦮", - "guinea": "🇬🇳", - "guinea_bissau": "🇬🇼", - "guitar": "🎸", - "gun": "🔫", - "guyana": "🇬🇾", - "haircut": "💇", - "haircut_man": "💇‍♂️", - "haircut_woman": "💇‍♀️", - "haiti": "🇭🇹", - "hamburger": "🍔", - "hammer": "🔨", - "hammer_and_pick": "⚒️", - "hammer_and_wrench": "🛠️", - "hamster": "🐹", - "hand": "✋", - "hand_over_mouth": "🤭", - "handbag": "👜", - "handball_person": "🤾", - "handshake": "🤝", - "hankey": "💩", - "hash": "#️⃣", - "hatched_chick": "🐥", - "hatching_chick": "🐣", - "headphones": "🎧", - "headstone": "🪦", - "health_worker": "🧑‍⚕️", - "hear_no_evil": "🙉", - "heard_mcdonald_islands": "🇭🇲", - "heart": "❤️", - "heart_decoration": "💟", - "heart_eyes": "😍", - "heart_eyes_cat": "😻", - "heart_on_fire": "❤️‍🔥", - "heartbeat": "💓", - "heartpulse": "💗", - "hearts": "♥️", - "heavy_check_mark": "✔️", - "heavy_division_sign": "➗", - "heavy_dollar_sign": "💲", - "heavy_exclamation_mark": "❗", - "heavy_heart_exclamation": "❣️", - "heavy_minus_sign": "➖", - "heavy_multiplication_x": "✖️", - "heavy_plus_sign": "➕", - "hedgehog": "🦔", - "helicopter": "🚁", - "herb": "🌿", - "hibiscus": "🌺", - "high_brightness": "🔆", - "high_heel": "👠", - "hiking_boot": "🥾", - "hindu_temple": "🛕", - "hippopotamus": "🦛", - "hocho": "🔪", - "hole": "🕳️", - "honduras": "🇭🇳", - "honey_pot": "🍯", - "honeybee": "🐝", - "hong_kong": "🇭🇰", - "hook": "🪝", - "horse": "🐴", - "horse_racing": "🏇", - "hospital": "🏥", - "hot_face": "🥵", - "hot_pepper": "🌶️", - "hotdog": "🌭", - "hotel": "🏨", - "hotsprings": "♨️", - "hourglass": "⌛", - "hourglass_flowing_sand": "⏳", - "house": "🏠", - "house_with_garden": "🏡", - "houses": "🏘️", - "hugs": "🤗", - "hungary": "🇭🇺", - "hushed": "😯", - "hut": "🛖", - "ice_cream": "🍨", - "ice_cube": "🧊", - "ice_hockey": "🏒", - "ice_skate": "⛸️", - "icecream": "🍦", - "iceland": "🇮🇸", - "id": "🆔", - "ideograph_advantage": "🉐", - "imp": "👿", - "inbox_tray": "📥", - "incoming_envelope": "📨", - "india": "🇮🇳", - "indonesia": "🇮🇩", - "infinity": "♾️", - "information_desk_person": "💁", - "information_source": "ℹ️", - "innocent": "😇", - "interrobang": "⁉️", - "iphone": "📱", - "iran": "🇮🇷", - "iraq": "🇮🇶", - "ireland": "🇮🇪", - "isle_of_man": "🇮🇲", - "israel": "🇮🇱", - "it": "🇮🇹", - "izakaya_lantern": "🏮", - "jack_o_lantern": "🎃", - "jamaica": "🇯🇲", - "japan": "🗾", - "japanese_castle": "🏯", - "japanese_goblin": "👺", - "japanese_ogre": "👹", - "jeans": "👖", - "jersey": "🇯🇪", - "jigsaw": "🧩", - "jordan": "🇯🇴", - "joy": "😂", - "joy_cat": "😹", - "joystick": "🕹️", - "jp": "🇯🇵", - "judge": "🧑‍⚖️", - "juggling_person": "🤹", - "kaaba": "🕋", - "kangaroo": "🦘", - "kazakhstan": "🇰🇿", - "kenya": "🇰🇪", - "key": "🔑", - "keyboard": "⌨️", - "keycap_ten": "🔟", - "kick_scooter": "🛴", - "kimono": "👘", - "kiribati": "🇰🇮", - "kiss": "💋", - "kissing": "😗", - "kissing_cat": "😽", - "kissing_closed_eyes": "😚", - "kissing_heart": "😘", - "kissing_smiling_eyes": "😙", - "kite": "🪁", - "kiwi_fruit": "🥝", - "kneeling_man": "🧎‍♂️", - "kneeling_person": "🧎", - "kneeling_woman": "🧎‍♀️", - "knife": "🔪", - "knot": "🪢", - "koala": "🐨", - "koko": "🈁", - "kosovo": "🇽🇰", - "kr": "🇰🇷", - "kuwait": "🇰🇼", - "kyrgyzstan": "🇰🇬", - "lab_coat": "🥼", - "label": "🏷️", - "lacrosse": "🥍", - "ladder": "🪜", - "lady_beetle": "🐞", - "lantern": "🏮", - "laos": "🇱🇦", - "large_blue_circle": "🔵", - "large_blue_diamond": "🔷", - "large_orange_diamond": "🔶", - "last_quarter_moon": "🌗", - "last_quarter_moon_with_face": "🌜", - "latin_cross": "✝️", - "latvia": "🇱🇻", - "laughing": "😆", - "leafy_green": "🥬", - "leaves": "🍃", - "lebanon": "🇱🇧", - "ledger": "📒", - "left_luggage": "🛅", - "left_right_arrow": "↔️", - "left_speech_bubble": "🗨️", - "leftwards_arrow_with_hook": "↩️", - "leg": "🦵", - "lemon": "🍋", - "leo": "♌", - "leopard": "🐆", - "lesotho": "🇱🇸", - "level_slider": "🎚️", - "liberia": "🇱🇷", - "libra": "♎", - "libya": "🇱🇾", - "liechtenstein": "🇱🇮", - "light_rail": "🚈", - "link": "🔗", - "lion": "🦁", - "lips": "👄", - "lipstick": "💄", - "lithuania": "🇱🇹", - "lizard": "🦎", - "llama": "🦙", - "lobster": "🦞", - "lock": "🔒", - "lock_with_ink_pen": "🔏", - "lollipop": "🍭", - "long_drum": "🪘", - "loop": "➿", - "lotion_bottle": "🧴", - "lotus_position": "🧘", - "lotus_position_man": "🧘‍♂️", - "lotus_position_woman": "🧘‍♀️", - "loud_sound": "🔊", - "loudspeaker": "📢", - "love_hotel": "🏩", - "love_letter": "💌", - "love_you_gesture": "🤟", - "low_brightness": "🔅", - "luggage": "🧳", - "lungs": "🫁", - "luxembourg": "🇱🇺", - "lying_face": "🤥", - "m": "Ⓜ️", - "macau": "🇲🇴", - "macedonia": "🇲🇰", - "madagascar": "🇲🇬", - "mag": "🔍", - "mag_right": "🔎", - "mage": "🧙", - "mage_man": "🧙‍♂️", - "mage_woman": "🧙‍♀️", - "magic_wand": "🪄", - "magnet": "🧲", - "mahjong": "🀄", - "mailbox": "📫", - "mailbox_closed": "📪", - "mailbox_with_mail": "📬", - "mailbox_with_no_mail": "📭", - "malawi": "🇲🇼", - "malaysia": "🇲🇾", - "maldives": "🇲🇻", - "male_detective": "🕵️‍♂️", - "male_sign": "♂️", - "mali": "🇲🇱", - "malta": "🇲🇹", - "mammoth": "🦣", - "man": "👨", - "man_artist": "👨‍🎨", - "man_astronaut": "👨‍🚀", - "man_beard": "🧔‍♂️", - "man_cartwheeling": "🤸‍♂️", - "man_cook": "👨‍🍳", - "man_dancing": "🕺", - "man_facepalming": "🤦‍♂️", - "man_factory_worker": "👨‍🏭", - "man_farmer": "👨‍🌾", - "man_feeding_baby": "👨‍🍼", - "man_firefighter": "👨‍🚒", - "man_health_worker": "👨‍⚕️", - "man_in_manual_wheelchair": "👨‍🦽", - "man_in_motorized_wheelchair": "👨‍🦼", - "man_in_tuxedo": "🤵‍♂️", - "man_judge": "👨‍⚖️", - "man_juggling": "🤹‍♂️", - "man_mechanic": "👨‍🔧", - "man_office_worker": "👨‍💼", - "man_pilot": "👨‍✈️", - "man_playing_handball": "🤾‍♂️", - "man_playing_water_polo": "🤽‍♂️", - "man_scientist": "👨‍🔬", - "man_shrugging": "🤷‍♂️", - "man_singer": "👨‍🎤", - "man_student": "👨‍🎓", - "man_teacher": "👨‍🏫", - "man_technologist": "👨‍💻", - "man_with_gua_pi_mao": "👲", - "man_with_probing_cane": "👨‍🦯", - "man_with_turban": "👳‍♂️", - "man_with_veil": "👰‍♂️", - "mandarin": "🍊", - "mango": "🥭", - "mans_shoe": "👞", - "mantelpiece_clock": "🕰️", - "manual_wheelchair": "🦽", - "maple_leaf": "🍁", - "marshall_islands": "🇲🇭", - "martial_arts_uniform": "🥋", - "martinique": "🇲🇶", - "mask": "😷", - "massage": "💆", - "massage_man": "💆‍♂️", - "massage_woman": "💆‍♀️", - "mate": "🧉", - "mauritania": "🇲🇷", - "mauritius": "🇲🇺", - "mayotte": "🇾🇹", - "meat_on_bone": "🍖", - "mechanic": "🧑‍🔧", - "mechanical_arm": "🦾", - "mechanical_leg": "🦿", - "medal_military": "🎖️", - "medal_sports": "🏅", - "medical_symbol": "⚕️", - "mega": "📣", - "melon": "🍈", - "memo": "📝", - "men_wrestling": "🤼‍♂️", - "mending_heart": "❤️‍🩹", - "menorah": "🕎", - "mens": "🚹", - "mermaid": "🧜‍♀️", - "merman": "🧜‍♂️", - "merperson": "🧜", - "metal": "🤘", - "metro": "🚇", - "mexico": "🇲🇽", - "microbe": "🦠", - "micronesia": "🇫🇲", - "microphone": "🎤", - "microscope": "🔬", - "middle_finger": "🖕", - "military_helmet": "🪖", - "milk_glass": "🥛", - "milky_way": "🌌", - "minibus": "🚐", - "minidisc": "💽", - "mirror": "🪞", - "mobile_phone_off": "📴", - "moldova": "🇲🇩", - "monaco": "🇲🇨", - "money_mouth_face": "🤑", - "money_with_wings": "💸", - "moneybag": "💰", - "mongolia": "🇲🇳", - "monkey": "🐒", - "monkey_face": "🐵", - "monocle_face": "🧐", - "monorail": "🚝", - "montenegro": "🇲🇪", - "montserrat": "🇲🇸", - "moon": "🌔", - "moon_cake": "🥮", - "morocco": "🇲🇦", - "mortar_board": "🎓", - "mosque": "🕌", - "mosquito": "🦟", - "motor_boat": "🛥️", - "motor_scooter": "🛵", - "motorcycle": "🏍️", - "motorized_wheelchair": "🦼", - "motorway": "🛣️", - "mount_fuji": "🗻", - "mountain": "⛰️", - "mountain_bicyclist": "🚵", - "mountain_biking_man": "🚵‍♂️", - "mountain_biking_woman": "🚵‍♀️", - "mountain_cableway": "🚠", - "mountain_railway": "🚞", - "mountain_snow": "🏔️", - "mouse": "🐭", - "mouse2": "🐁", - "mouse_trap": "🪤", - "movie_camera": "🎥", - "moyai": "🗿", - "mozambique": "🇲🇿", - "mrs_claus": "🤶", - "muscle": "💪", - "mushroom": "🍄", - "musical_keyboard": "🎹", - "musical_note": "🎵", - "musical_score": "🎼", - "mute": "🔇", - "mx_claus": "🧑‍🎄", - "myanmar": "🇲🇲", - "nail_care": "💅", - "name_badge": "📛", - "namibia": "🇳🇦", - "national_park": "🏞️", - "nauru": "🇳🇷", - "nauseated_face": "🤢", - "nazar_amulet": "🧿", - "necktie": "👔", - "negative_squared_cross_mark": "❎", - "nepal": "🇳🇵", - "nerd_face": "🤓", - "nesting_dolls": "🪆", - "netherlands": "🇳🇱", - "neutral_face": "😐", - "new": "🆕", - "new_caledonia": "🇳🇨", - "new_moon": "🌑", - "new_moon_with_face": "🌚", - "new_zealand": "🇳🇿", - "newspaper": "📰", - "newspaper_roll": "🗞️", - "next_track_button": "⏭️", - "ng": "🆖", - "ng_man": "🙅‍♂️", - "ng_woman": "🙅‍♀️", - "nicaragua": "🇳🇮", - "niger": "🇳🇪", - "nigeria": "🇳🇬", - "night_with_stars": "🌃", - "nine": "9️⃣", - "ninja": "🥷", - "niue": "🇳🇺", - "no_bell": "🔕", - "no_bicycles": "🚳", - "no_entry": "⛔", - "no_entry_sign": "🚫", - "no_good": "🙅", - "no_good_man": "🙅‍♂️", - "no_good_woman": "🙅‍♀️", - "no_mobile_phones": "📵", - "no_mouth": "😶", - "no_pedestrians": "🚷", - "no_smoking": "🚭", - "non-potable_water": "🚱", - "norfolk_island": "🇳🇫", - "north_korea": "🇰🇵", - "northern_mariana_islands": "🇲🇵", - "norway": "🇳🇴", - "nose": "👃", - "notebook": "📓", - "notebook_with_decorative_cover": "📔", - "notes": "🎶", - "nut_and_bolt": "🔩", - "o": "⭕", - "o2": "🅾️", - "ocean": "🌊", - "octopus": "🐙", - "oden": "🍢", - "office": "🏢", - "office_worker": "🧑‍💼", - "oil_drum": "🛢️", - "ok": "🆗", - "ok_hand": "👌", - "ok_man": "🙆‍♂️", - "ok_person": "🙆", - "ok_woman": "🙆‍♀️", - "old_key": "🗝️", - "older_adult": "🧓", - "older_man": "👴", - "older_woman": "👵", - "olive": "🫒", - "om": "🕉️", - "oman": "🇴🇲", - "on": "🔛", - "oncoming_automobile": "🚘", - "oncoming_bus": "🚍", - "oncoming_police_car": "🚔", - "oncoming_taxi": "🚖", - "one": "1️⃣", - "one_piece_swimsuit": "🩱", - "onion": "🧅", - "open_book": "📖", - "open_file_folder": "📂", - "open_hands": "👐", - "open_mouth": "😮", - "open_umbrella": "☂️", - "ophiuchus": "⛎", - "orange": "🍊", - "orange_book": "📙", - "orange_circle": "🟠", - "orange_heart": "🧡", - "orange_square": "🟧", - "orangutan": "🦧", - "orthodox_cross": "☦️", - "otter": "🦦", - "outbox_tray": "📤", - "owl": "🦉", - "ox": "🐂", - "oyster": "🦪", - "package": "📦", - "page_facing_up": "📄", - "page_with_curl": "📃", - "pager": "📟", - "paintbrush": "🖌️", - "pakistan": "🇵🇰", - "palau": "🇵🇼", - "palestinian_territories": "🇵🇸", - "palm_tree": "🌴", - "palms_up_together": "🤲", - "panama": "🇵🇦", - "pancakes": "🥞", - "panda_face": "🐼", - "paperclip": "📎", - "paperclips": "🖇️", - "papua_new_guinea": "🇵🇬", - "parachute": "🪂", - "paraguay": "🇵🇾", - "parasol_on_ground": "⛱️", - "parking": "🅿️", - "parrot": "🦜", - "part_alternation_mark": "〽️", - "partly_sunny": "⛅", - "partying_face": "🥳", - "passenger_ship": "🛳️", - "passport_control": "🛂", - "pause_button": "⏸️", - "paw_prints": "🐾", - "peace_symbol": "☮️", - "peach": "🍑", - "peacock": "🦚", - "peanuts": "🥜", - "pear": "🍐", - "pen": "🖊️", - "pencil": "📝", - "pencil2": "✏️", - "penguin": "🐧", - "pensive": "😔", - "people_holding_hands": "🧑‍🤝‍🧑", - "people_hugging": "🫂", - "performing_arts": "🎭", - "persevere": "😣", - "person_bald": "🧑‍🦲", - "person_curly_hair": "🧑‍🦱", - "person_feeding_baby": "🧑‍🍼", - "person_fencing": "🤺", - "person_in_manual_wheelchair": "🧑‍🦽", - "person_in_motorized_wheelchair": "🧑‍🦼", - "person_in_tuxedo": "🤵", - "person_red_hair": "🧑‍🦰", - "person_white_hair": "🧑‍🦳", - "person_with_probing_cane": "🧑‍🦯", - "person_with_turban": "👳", - "person_with_veil": "👰", - "peru": "🇵🇪", - "petri_dish": "🧫", - "philippines": "🇵🇭", - "phone": "☎️", - "pick": "⛏️", - "pickup_truck": "🛻", - "pie": "🥧", - "pig": "🐷", - "pig2": "🐖", - "pig_nose": "🐽", - "pill": "💊", - "pilot": "🧑‍✈️", - "pinata": "🪅", - "pinched_fingers": "🤌", - "pinching_hand": "🤏", - "pineapple": "🍍", - "ping_pong": "🏓", - "pirate_flag": "🏴‍☠️", - "pisces": "♓", - "pitcairn_islands": "🇵🇳", - "pizza": "🍕", - "placard": "🪧", - "place_of_worship": "🛐", - "plate_with_cutlery": "🍽️", - "play_or_pause_button": "⏯️", - "pleading_face": "🥺", - "plunger": "🪠", - "point_down": "👇", - "point_left": "👈", - "point_right": "👉", - "point_up": "☝️", - "point_up_2": "👆", - "poland": "🇵🇱", - "polar_bear": "🐻‍❄️", - "police_car": "🚓", - "police_officer": "👮", - "policeman": "👮‍♂️", - "policewoman": "👮‍♀️", - "poodle": "🐩", - "poop": "💩", - "popcorn": "🍿", - "portugal": "🇵🇹", - "post_office": "🏣", - "postal_horn": "📯", - "postbox": "📮", - "potable_water": "🚰", - "potato": "🥔", - "potted_plant": "🪴", - "pouch": "👝", - "poultry_leg": "🍗", - "pound": "💷", - "pout": "😡", - "pouting_cat": "😾", - "pouting_face": "🙎", - "pouting_man": "🙎‍♂️", - "pouting_woman": "🙎‍♀️", - "pray": "🙏", - "prayer_beads": "📿", - "pregnant_woman": "🤰", - "pretzel": "🥨", - "previous_track_button": "⏮️", - "prince": "🤴", - "princess": "👸", - "printer": "🖨️", - "probing_cane": "🦯", - "puerto_rico": "🇵🇷", - "punch": "👊", - "purple_circle": "🟣", - "purple_heart": "💜", - "purple_square": "🟪", - "purse": "👛", - "pushpin": "📌", - "put_litter_in_its_place": "🚮", - "qatar": "🇶🇦", - "question": "❓", - "rabbit": "🐰", - "rabbit2": "🐇", - "raccoon": "🦝", - "racehorse": "🐎", - "racing_car": "🏎️", - "radio": "📻", - "radio_button": "🔘", - "radioactive": "☢️", - "rage": "😡", - "railway_car": "🚃", - "railway_track": "🛤️", - "rainbow": "🌈", - "rainbow_flag": "🏳️‍🌈", - "raised_back_of_hand": "🤚", - "raised_eyebrow": "🤨", - "raised_hand": "✋", - "raised_hand_with_fingers_splayed": "🖐️", - "raised_hands": "🙌", - "raising_hand": "🙋", - "raising_hand_man": "🙋‍♂️", - "raising_hand_woman": "🙋‍♀️", - "ram": "🐏", - "ramen": "🍜", - "rat": "🐀", - "razor": "🪒", - "receipt": "🧾", - "record_button": "⏺️", - "recycle": "♻️", - "red_car": "🚗", - "red_circle": "🔴", - "red_envelope": "🧧", - "red_haired_man": "👨‍🦰", - "red_haired_woman": "👩‍🦰", - "red_square": "🟥", - "registered": "®️", - "relaxed": "☺️", - "relieved": "😌", - "reminder_ribbon": "🎗️", - "repeat": "🔁", - "repeat_one": "🔂", - "rescue_worker_helmet": "⛑️", - "restroom": "🚻", - "reunion": "🇷🇪", - "revolving_hearts": "💞", - "rewind": "⏪", - "rhinoceros": "🦏", - "ribbon": "🎀", - "rice": "🍚", - "rice_ball": "🍙", - "rice_cracker": "🍘", - "rice_scene": "🎑", - "right_anger_bubble": "🗯️", - "ring": "💍", - "ringed_planet": "🪐", - "robot": "🤖", - "rock": "🪨", - "rocket": "🚀", - "rofl": "🤣", - "roll_eyes": "🙄", - "roll_of_paper": "🧻", - "roller_coaster": "🎢", - "roller_skate": "🛼", - "romania": "🇷🇴", - "rooster": "🐓", - "rose": "🌹", - "rosette": "🏵️", - "rotating_light": "🚨", - "round_pushpin": "📍", - "rowboat": "🚣", - "rowing_man": "🚣‍♂️", - "rowing_woman": "🚣‍♀️", - "ru": "🇷🇺", - "rugby_football": "🏉", - "runner": "🏃", - "running": "🏃", - "running_man": "🏃‍♂️", - "running_shirt_with_sash": "🎽", - "running_woman": "🏃‍♀️", - "rwanda": "🇷🇼", - "sa": "🈂️", - "safety_pin": "🧷", - "safety_vest": "🦺", - "sagittarius": "♐", - "sailboat": "⛵", - "sake": "🍶", - "salt": "🧂", - "samoa": "🇼🇸", - "san_marino": "🇸🇲", - "sandal": "👡", - "sandwich": "🥪", - "santa": "🎅", - "sao_tome_principe": "🇸🇹", - "sari": "🥻", - "sassy_man": "💁‍♂️", - "sassy_woman": "💁‍♀️", - "satellite": "📡", - "satisfied": "😆", - "saudi_arabia": "🇸🇦", - "sauna_man": "🧖‍♂️", - "sauna_person": "🧖", - "sauna_woman": "🧖‍♀️", - "sauropod": "🦕", - "saxophone": "🎷", - "scarf": "🧣", - "school": "🏫", - "school_satchel": "🎒", - "scientist": "🧑‍🔬", - "scissors": "✂️", - "scorpion": "🦂", - "scorpius": "♏", - "scotland": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", - "scream": "😱", - "scream_cat": "🙀", - "screwdriver": "🪛", - "scroll": "📜", - "seal": "🦭", - "seat": "💺", - "secret": "㊙️", - "see_no_evil": "🙈", - "seedling": "🌱", - "selfie": "🤳", - "senegal": "🇸🇳", - "serbia": "🇷🇸", - "service_dog": "🐕‍🦺", - "seven": "7️⃣", - "sewing_needle": "🪡", - "seychelles": "🇸🇨", - "shallow_pan_of_food": "🥘", - "shamrock": "☘️", - "shark": "🦈", - "shaved_ice": "🍧", - "sheep": "🐑", - "shell": "🐚", - "shield": "🛡️", - "shinto_shrine": "⛩️", - "ship": "🚢", - "shirt": "👕", - "shit": "💩", - "shoe": "👞", - "shopping": "🛍️", - "shopping_cart": "🛒", - "shorts": "🩳", - "shower": "🚿", - "shrimp": "🦐", - "shrug": "🤷", - "shushing_face": "🤫", - "sierra_leone": "🇸🇱", - "signal_strength": "📶", - "singapore": "🇸🇬", - "singer": "🧑‍🎤", - "sint_maarten": "🇸🇽", - "six": "6️⃣", - "six_pointed_star": "🔯", - "skateboard": "🛹", - "ski": "🎿", - "skier": "⛷️", - "skull": "💀", - "skull_and_crossbones": "☠️", - "skunk": "🦨", - "sled": "🛷", - "sleeping": "😴", - "sleeping_bed": "🛌", - "sleepy": "😪", - "slightly_frowning_face": "🙁", - "slightly_smiling_face": "🙂", - "slot_machine": "🎰", - "sloth": "🦥", - "slovakia": "🇸🇰", - "slovenia": "🇸🇮", - "small_airplane": "🛩️", - "small_blue_diamond": "🔹", - "small_orange_diamond": "🔸", - "small_red_triangle": "🔺", - "small_red_triangle_down": "🔻", - "smile": "😄", - "smile_cat": "😸", - "smiley": "😃", - "smiley_cat": "😺", - "smiling_face_with_tear": "🥲", - "smiling_face_with_three_hearts": "🥰", - "smiling_imp": "😈", - "smirk": "😏", - "smirk_cat": "😼", - "smoking": "🚬", - "snail": "🐌", - "snake": "🐍", - "sneezing_face": "🤧", - "snowboarder": "🏂", - "snowflake": "❄️", - "snowman": "⛄", - "snowman_with_snow": "☃️", - "soap": "🧼", - "sob": "😭", - "soccer": "⚽", - "socks": "🧦", - "softball": "🥎", - "solomon_islands": "🇸🇧", - "somalia": "🇸🇴", - "soon": "🔜", - "sos": "🆘", - "sound": "🔉", - "south_africa": "🇿🇦", - "south_georgia_south_sandwich_islands": "🇬🇸", - "south_sudan": "🇸🇸", - "space_invader": "👾", - "spades": "♠️", - "spaghetti": "🍝", - "sparkle": "❇️", - "sparkler": "🎇", - "sparkles": "✨", - "sparkling_heart": "💖", - "speak_no_evil": "🙊", - "speaker": "🔈", - "speaking_head": "🗣️", - "speech_balloon": "💬", - "speedboat": "🚤", - "spider": "🕷️", - "spider_web": "🕸️", - "spiral_calendar": "🗓️", - "spiral_notepad": "🗒️", - "sponge": "🧽", - "spoon": "🥄", - "squid": "🦑", - "sri_lanka": "🇱🇰", - "st_barthelemy": "🇧🇱", - "st_helena": "🇸🇭", - "st_kitts_nevis": "🇰🇳", - "st_lucia": "🇱🇨", - "st_martin": "🇲🇫", - "st_pierre_miquelon": "🇵🇲", - "st_vincent_grenadines": "🇻🇨", - "stadium": "🏟️", - "standing_man": "🧍‍♂️", - "standing_person": "🧍", - "standing_woman": "🧍‍♀️", - "star": "⭐", - "star2": "🌟", - "star_and_crescent": "☪️", - "star_of_david": "✡️", - "star_struck": "🤩", - "stars": "🌠", - "station": "🚉", - "statue_of_liberty": "🗽", - "steam_locomotive": "🚂", - "stethoscope": "🩺", - "stew": "🍲", - "stop_button": "⏹️", - "stop_sign": "🛑", - "stopwatch": "⏱️", - "straight_ruler": "📏", - "strawberry": "🍓", - "stuck_out_tongue": "😛", - "stuck_out_tongue_closed_eyes": "😝", - "stuck_out_tongue_winking_eye": "😜", - "student": "🧑‍🎓", - "studio_microphone": "🎙️", - "stuffed_flatbread": "🥙", - "sudan": "🇸🇩", - "sun_behind_large_cloud": "🌥️", - "sun_behind_rain_cloud": "🌦️", - "sun_behind_small_cloud": "🌤️", - "sun_with_face": "🌞", - "sunflower": "🌻", - "sunglasses": "😎", - "sunny": "☀️", - "sunrise": "🌅", - "sunrise_over_mountains": "🌄", - "superhero": "🦸", - "superhero_man": "🦸‍♂️", - "superhero_woman": "🦸‍♀️", - "supervillain": "🦹", - "supervillain_man": "🦹‍♂️", - "supervillain_woman": "🦹‍♀️", - "surfer": "🏄", - "surfing_man": "🏄‍♂️", - "surfing_woman": "🏄‍♀️", - "suriname": "🇸🇷", - "sushi": "🍣", - "suspension_railway": "🚟", - "svalbard_jan_mayen": "🇸🇯", - "swan": "🦢", - "swaziland": "🇸🇿", - "sweat": "😓", - "sweat_drops": "💦", - "sweat_smile": "😅", - "sweden": "🇸🇪", - "sweet_potato": "🍠", - "swim_brief": "🩲", - "swimmer": "🏊", - "swimming_man": "🏊‍♂️", - "swimming_woman": "🏊‍♀️", - "switzerland": "🇨🇭", - "symbols": "🔣", - "synagogue": "🕍", - "syria": "🇸🇾", - "syringe": "💉", - "t-rex": "🦖", - "taco": "🌮", - "tada": "🎉", - "taiwan": "🇹🇼", - "tajikistan": "🇹🇯", - "takeout_box": "🥡", - "tamale": "🫔", - "tanabata_tree": "🎋", - "tangerine": "🍊", - "tanzania": "🇹🇿", - "taurus": "♉", - "taxi": "🚕", - "tea": "🍵", - "teacher": "🧑‍🏫", - "teapot": "🫖", - "technologist": "🧑‍💻", - "teddy_bear": "🧸", - "telephone": "☎️", - "telephone_receiver": "📞", - "telescope": "🔭", - "tennis": "🎾", - "tent": "⛺", - "test_tube": "🧪", - "thailand": "🇹🇭", - "thermometer": "🌡️", - "thinking": "🤔", - "thong_sandal": "🩴", - "thought_balloon": "💭", - "thread": "🧵", - "three": "3️⃣", - "thumbsdown": "👎", - "thumbsup": "👍", - "ticket": "🎫", - "tickets": "🎟️", - "tiger": "🐯", - "tiger2": "🐅", - "timer_clock": "⏲️", - "timor_leste": "🇹🇱", - "tipping_hand_man": "💁‍♂️", - "tipping_hand_person": "💁", - "tipping_hand_woman": "💁‍♀️", - "tired_face": "😫", - "tm": "™️", - "togo": "🇹🇬", - "toilet": "🚽", - "tokelau": "🇹🇰", - "tokyo_tower": "🗼", - "tomato": "🍅", - "tonga": "🇹🇴", - "tongue": "👅", - "toolbox": "🧰", - "tooth": "🦷", - "toothbrush": "🪥", - "top": "🔝", - "tophat": "🎩", - "tornado": "🌪️", - "tr": "🇹🇷", - "trackball": "🖲️", - "tractor": "🚜", - "traffic_light": "🚥", - "train": "🚋", - "train2": "🚆", - "tram": "🚊", - "transgender_flag": "🏳️‍⚧️", - "transgender_symbol": "⚧️", - "triangular_flag_on_post": "🚩", - "triangular_ruler": "📐", - "trident": "🔱", - "trinidad_tobago": "🇹🇹", - "tristan_da_cunha": "🇹🇦", - "triumph": "😤", - "trolleybus": "🚎", - "trophy": "🏆", - "tropical_drink": "🍹", - "tropical_fish": "🐠", - "truck": "🚚", - "trumpet": "🎺", - "tshirt": "👕", - "tulip": "🌷", - "tumbler_glass": "🥃", - "tunisia": "🇹🇳", - "turkey": "🦃", - "turkmenistan": "🇹🇲", - "turks_caicos_islands": "🇹🇨", - "turtle": "🐢", - "tuvalu": "🇹🇻", - "tv": "📺", - "twisted_rightwards_arrows": "🔀", - "two": "2️⃣", - "two_hearts": "💕", - "two_men_holding_hands": "👬", - "two_women_holding_hands": "👭", - "u5272": "🈹", - "u5408": "🈴", - "u55b6": "🈺", - "u6307": "🈯", - "u6708": "🈷️", - "u6709": "🈶", - "u6e80": "🈵", - "u7121": "🈚", - "u7533": "🈸", - "u7981": "🈲", - "u7a7a": "🈳", - "uganda": "🇺🇬", - "uk": "🇬🇧", - "ukraine": "🇺🇦", - "umbrella": "☔", - "unamused": "😒", - "underage": "🔞", - "unicorn": "🦄", - "united_arab_emirates": "🇦🇪", - "united_nations": "🇺🇳", - "unlock": "🔓", - "up": "🆙", - "upside_down_face": "🙃", - "uruguay": "🇺🇾", - "us": "🇺🇸", - "us_outlying_islands": "🇺🇲", - "us_virgin_islands": "🇻🇮", - "uzbekistan": "🇺🇿", - "v": "✌️", - "vampire": "🧛", - "vampire_man": "🧛‍♂️", - "vampire_woman": "🧛‍♀️", - "vanuatu": "🇻🇺", - "vatican_city": "🇻🇦", - "venezuela": "🇻🇪", - "vertical_traffic_light": "🚦", - "vhs": "📼", - "vibration_mode": "📳", - "video_camera": "📹", - "video_game": "🎮", - "vietnam": "🇻🇳", - "violin": "🎻", - "virgo": "♍", - "volcano": "🌋", - "volleyball": "🏐", - "vomiting_face": "🤮", - "vs": "🆚", - "vulcan_salute": "🖖", - "waffle": "🧇", - "wales": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", - "walking": "🚶", - "walking_man": "🚶‍♂️", - "walking_woman": "🚶‍♀️", - "wallis_futuna": "🇼🇫", - "waning_crescent_moon": "🌘", - "waning_gibbous_moon": "🌖", - "warning": "⚠️", - "wastebasket": "🗑️", - "watch": "⌚", - "water_buffalo": "🐃", - "water_polo": "🤽", - "watermelon": "🍉", - "wave": "👋", - "wavy_dash": "〰️", - "waxing_crescent_moon": "🌒", - "waxing_gibbous_moon": "🌔", - "wc": "🚾", - "weary": "😩", - "wedding": "💒", - "weight_lifting": "🏋️", - "weight_lifting_man": "🏋️‍♂️", - "weight_lifting_woman": "🏋️‍♀️", - "western_sahara": "🇪🇭", - "whale": "🐳", - "whale2": "🐋", - "wheel_of_dharma": "☸️", - "wheelchair": "♿", - "white_check_mark": "✅", - "white_circle": "⚪", - "white_flag": "🏳️", - "white_flower": "💮", - "white_haired_man": "👨‍🦳", - "white_haired_woman": "👩‍🦳", - "white_heart": "🤍", - "white_large_square": "⬜", - "white_medium_small_square": "◽", - "white_medium_square": "◻️", - "white_small_square": "▫️", - "white_square_button": "🔳", - "wilted_flower": "🥀", - "wind_chime": "🎐", - "wind_face": "🌬️", - "window": "🪟", - "wine_glass": "🍷", - "wink": "😉", - "wolf": "🐺", - "woman": "👩", - "woman_artist": "👩‍🎨", - "woman_astronaut": "👩‍🚀", - "woman_beard": "🧔‍♀️", - "woman_cartwheeling": "🤸‍♀️", - "woman_cook": "👩‍🍳", - "woman_dancing": "💃", - "woman_facepalming": "🤦‍♀️", - "woman_factory_worker": "👩‍🏭", - "woman_farmer": "👩‍🌾", - "woman_feeding_baby": "👩‍🍼", - "woman_firefighter": "👩‍🚒", - "woman_health_worker": "👩‍⚕️", - "woman_in_manual_wheelchair": "👩‍🦽", - "woman_in_motorized_wheelchair": "👩‍🦼", - "woman_in_tuxedo": "🤵‍♀️", - "woman_judge": "👩‍⚖️", - "woman_juggling": "🤹‍♀️", - "woman_mechanic": "👩‍🔧", - "woman_office_worker": "👩‍💼", - "woman_pilot": "👩‍✈️", - "woman_playing_handball": "🤾‍♀️", - "woman_playing_water_polo": "🤽‍♀️", - "woman_scientist": "👩‍🔬", - "woman_shrugging": "🤷‍♀️", - "woman_singer": "👩‍🎤", - "woman_student": "👩‍🎓", - "woman_teacher": "👩‍🏫", - "woman_technologist": "👩‍💻", - "woman_with_headscarf": "🧕", - "woman_with_probing_cane": "👩‍🦯", - "woman_with_turban": "👳‍♀️", - "woman_with_veil": "👰‍♀️", - "womans_clothes": "👚", - "womans_hat": "👒", - "women_wrestling": "🤼‍♀️", - "womens": "🚺", - "wood": "🪵", - "woozy_face": "🥴", - "world_map": "🗺️", - "worm": "🪱", - "worried": "😟", - "wrench": "🔧", - "wrestling": "🤼", - "writing_hand": "✍️", - "x": "❌", - "yarn": "🧶", - "yawning_face": "🥱", - "yellow_circle": "🟡", - "yellow_heart": "💛", - "yellow_square": "🟨", - "yemen": "🇾🇪", - "yen": "💴", - "yin_yang": "☯️", - "yo_yo": "🪀", - "yum": "😋", - "zambia": "🇿🇲", - "zany_face": "🤪", - "zap": "⚡", - "zebra": "🦓", - "zero": "0️⃣", - "zimbabwe": "🇿🇼", - "zipper_mouth_face": "🤐", - "zombie": "🧟", - "zombie_man": "🧟‍♂️", - "zombie_woman": "🧟‍♀️", - "zzz": "💤" -} \ No newline at end of file diff --git a/server/message_cache.go b/server/message_cache.go index 1d7302a..37683d6 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -17,7 +17,6 @@ import ( var ( errUnexpectedMessageType = errors.New("unexpected message type") errMessageNotFound = errors.New("message not found") - errNoRows = errors.New("no rows found") ) // Messages cache @@ -55,11 +54,6 @@ const ( CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); CREATE INDEX IF NOT EXISTS idx_user ON messages (user); CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires); - CREATE TABLE IF NOT EXISTS stats ( - key TEXT PRIMARY KEY, - value INT - ); - INSERT INTO stats (key, value) VALUES ('messages', 0); COMMIT; ` insertMessageQuery = ` @@ -114,14 +108,11 @@ const ( selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0` selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?` selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?` - - selectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'` - updateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'` ) // Schema management queries const ( - currentSchemaVersion = 11 + currentSchemaVersion = 10 createSchemaVersionTableQuery = ` CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, @@ -231,30 +222,20 @@ const ( CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires); ` migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?` - - // 10 -> 11 - migrate10To11AlterMessagesTableQuery = ` - CREATE TABLE IF NOT EXISTS stats ( - key TEXT PRIMARY KEY, - value INT - ); - INSERT INTO stats (key, value) VALUES ('messages', 0); - ` ) var ( migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{ - 0: migrateFrom0, - 1: migrateFrom1, - 2: migrateFrom2, - 3: migrateFrom3, - 4: migrateFrom4, - 5: migrateFrom5, - 6: migrateFrom6, - 7: migrateFrom7, - 8: migrateFrom8, - 9: migrateFrom9, - 10: migrateFrom10, + 0: migrateFrom0, + 1: migrateFrom1, + 2: migrateFrom2, + 3: migrateFrom3, + 4: migrateFrom4, + 5: migrateFrom5, + 6: migrateFrom6, + 7: migrateFrom7, + 8: migrateFrom8, + 9: migrateFrom9, } ) @@ -555,7 +536,7 @@ func (c *messageCache) ExpireMessages(topics ...string) error { } defer tx.Rollback() for _, t := range topics { - if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix()-1, t); err != nil { + if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix(), t); err != nil { return err } } @@ -725,26 +706,6 @@ func readMessage(rows *sql.Rows) (*message, error) { }, nil } -func (c *messageCache) UpdateStats(messages int64) error { - _, err := c.db.Exec(updateStatsQuery, messages) - return err -} - -func (c *messageCache) Stats() (messages int64, err error) { - rows, err := c.db.Query(selectStatsQuery) - if err != nil { - return 0, err - } - defer rows.Close() - if !rows.Next() { - return 0, errNoRows - } - if err := rows.Scan(&messages); err != nil { - return 0, err - } - return messages, nil -} - func (c *messageCache) Close() error { return c.db.Close() } @@ -928,19 +889,3 @@ func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error { } return tx.Commit() } - -func migrateFrom10(db *sql.DB, cacheDuration time.Duration) error { - log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate10To11AlterMessagesTableQuery); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 11); err != nil { - return err - } - return tx.Commit() -} diff --git a/server/server.go b/server/server.go index d2fac01..b9e7b17 100644 --- a/server/server.go +++ b/server/server.go @@ -11,7 +11,6 @@ import ( "fmt" "github.com/emersion/go-smtp" "github.com/gorilla/websocket" - "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/sync/errgroup" "heckel.io/ntfy/log" "heckel.io/ntfy/user" @@ -19,7 +18,6 @@ import ( "io" "net" "net/http" - "net/http/pprof" "net/netip" "net/url" "os" @@ -39,8 +37,6 @@ type Server struct { config *Config httpServer *http.Server httpsServer *http.Server - httpMetricsServer *http.Server - httpProfileServer *http.Server unixListener net.Listener smtpServer *smtp.Server smtpServerBackend *smtpBackend @@ -48,16 +44,14 @@ type Server struct { topics map[string]*topic visitors map[string]*visitor // ip: or user: firebaseClient *firebaseClient - messages int64 // Total number of messages (persisted if messageCache enabled) - messagesHistory []int64 // Last n values of the messages counter, used to determine rate - userManager *user.Manager // Might be nil! - messageCache *messageCache // Database that stores the messages - fileCache *fileCache // File system based cache that stores attachments - stripe stripeAPI // Stripe API, can be replaced with a mock - priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) - metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set + messages int64 + userManager *user.Manager // Might be nil! + messageCache *messageCache // Database that stores the messages + fileCache *fileCache // File system based cache that stores attachments + stripe stripeAPI // Stripe API, can be replaced with a mock + priceCache *util.LookupCache[map[string]string] // Stripe price ID -> formatted price closeChan chan bool - mu sync.RWMutex + mu sync.Mutex } // handleFunc extends the normal http.HandlerFunc to be able to easily return errors @@ -78,20 +72,14 @@ var ( webConfigPath = "/config.js" accountPath = "/account" matrixPushPath = "/_matrix/push/v1/notify" - metricsPath = "/metrics" apiHealthPath = "/v1/health" - apiStatsPath = "/v1/stats" - apiTiersPath = "/v1/tiers" - apiUsersPath = "/v1/users" - apiUsersAccessPath = "/v1/users/access" + apiTiers = "/v1/tiers" apiAccountPath = "/v1/account" apiAccountTokenPath = "/v1/account/token" apiAccountPasswordPath = "/v1/account/password" apiAccountSettingsPath = "/v1/account/settings" apiAccountSubscriptionPath = "/v1/account/subscription" apiAccountReservationPath = "/v1/account/reservation" - apiAccountPhonePath = "/v1/account/phone" - apiAccountPhoneVerifyPath = "/v1/account/phone/verify" apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -102,13 +90,13 @@ var ( docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) - phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`) //go:embed site - webFs embed.FS - webFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webFs} - webSiteDir = "/site" - webAppIndex = "/app.html" // React app + webFs embed.FS + webFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webFs} + webSiteDir = "/site" + webHomeIndex = "/home.html" // Landing page, only if "web-root: home" + webAppIndex = "/app.html" // React app //go:embed docs docsStaticFs embed.FS @@ -122,10 +110,7 @@ const ( newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages - jsonBodyBytesLimit = 16384 // Max number of bytes for a JSON request body - unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber - unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part - messagesHistoryMax = 10 // Number of message count values to keep in memory + jsonBodyBytesLimit = 16384 ) // WebSocket constants @@ -155,10 +140,6 @@ func New(conf *Config) (*Server, error) { if err != nil { return nil, err } - messages, err := messageCache.Stats() - if err != nil { - return nil, err - } var fileCache *fileCache if conf.AttachmentCacheDir != "" { fileCache, err = newFileCache(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit) @@ -179,26 +160,18 @@ func New(conf *Config) (*Server, error) { if err != nil { return nil, err } - // This awkward logic is required because Go is weird about nil types and interfaces. - // See issue #641, and https://go.dev/play/p/uur1flrv1t3 for an example - var auther user.Auther - if userManager != nil { - auther = userManager - } - firebaseClient = newFirebaseClient(sender, auther) + firebaseClient = newFirebaseClient(sender, userManager) } s := &Server{ - config: conf, - messageCache: messageCache, - fileCache: fileCache, - firebaseClient: firebaseClient, - smtpSender: mailer, - topics: topics, - userManager: userManager, - messages: messages, - messagesHistory: []int64{messages}, - visitors: make(map[string]*visitor), - stripe: stripe, + config: conf, + messageCache: messageCache, + fileCache: fileCache, + firebaseClient: firebaseClient, + smtpSender: mailer, + topics: topics, + userManager: userManager, + visitors: make(map[string]*visitor), + stripe: stripe, } s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration) return s, nil @@ -229,12 +202,6 @@ func (s *Server) Run() error { if s.config.SMTPServerListen != "" { listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen) } - if s.config.MetricsListenHTTP != "" { - listenStr += fmt.Sprintf(" %s[http/metrics]", s.config.MetricsListenHTTP) - } - if s.config.ProfileListenHTTP != "" { - listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP) - } log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String()) if log.IsFile() { fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version) @@ -281,28 +248,6 @@ func (s *Server) Run() error { errChan <- httpServer.Serve(s.unixListener) }() } - if s.config.MetricsListenHTTP != "" { - initMetrics() - s.httpMetricsServer = &http.Server{Addr: s.config.MetricsListenHTTP, Handler: promhttp.Handler()} - go func() { - errChan <- s.httpMetricsServer.ListenAndServe() - }() - } else if s.config.EnableMetrics { - initMetrics() - s.metricsHandler = promhttp.Handler() - } - if s.config.ProfileListenHTTP != "" { - profileMux := http.NewServeMux() - profileMux.HandleFunc("/debug/pprof/", pprof.Index) - profileMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) - profileMux.HandleFunc("/debug/pprof/profile", pprof.Profile) - profileMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) - profileMux.HandleFunc("/debug/pprof/trace", pprof.Trace) - s.httpProfileServer = &http.Server{Addr: s.config.ProfileListenHTTP, Handler: profileMux} - go func() { - errChan <- s.httpProfileServer.ListenAndServe() - }() - } if s.config.SMTPServerListen != "" { go func() { errChan <- s.runSMTPServer() @@ -346,6 +291,7 @@ func (s *Server) closeDatabases() { // handle is the main entry point for all HTTP requests func (s *Server) handle(w http.ResponseWriter, r *http.Request) { + w = newHTTPResponseWriter(w) // Avoid logging "superfluous response.WriteHeader call" warning v, err := s.maybeAuthenticate(r) // Note: Always returns v, even when error is returned if err != nil { s.handleError(w, r, v, err) @@ -363,9 +309,6 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { s.handleError(w, r, v, err) return } - if metricHTTPRequests != nil { - metricHTTPRequests.WithLabelValues("200", "20000", r.Method).Inc() - } }). Debug("HTTP request finished") } @@ -375,31 +318,25 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, if !ok { httpErr = errHTTPInternalError } - if metricHTTPRequests != nil { - metricHTTPRequests.WithLabelValues(fmt.Sprintf("%d", httpErr.HTTPCode), fmt.Sprintf("%d", httpErr.Code), r.Method).Inc() - } - isRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode) - isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode) - ev := logvr(v, r).Err(err) + isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains([]int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized}, httpErr.HTTPCode) if websocket.IsWebSocketUpgrade(r) { - ev.Tag(tagWebsocket).Fields(websocketErrorContext(err)) if isNormalError { - ev.Debug("WebSocket error (this error is okay, it happens a lot): %s", err.Error()) + logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Debug("WebSocket error (this error is okay, it happens a lot): %s", err.Error()) } else { - ev.Info("WebSocket error: %s", err.Error()) + logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Info("WebSocket error: %s", err.Error()) } return // Do not attempt to write to upgraded connection } - if isNormalError { - ev.Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code) - } else { - ev.Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code) - } - if isRateLimiting && s.config.StripeSecretKey != "" { - u := v.User() - if u == nil || u.Tier == nil { - httpErr = httpErr.Wrap("increase your limits with a paid plan, see %s", s.config.BaseURL) + if matrixErr, ok := err.(*errMatrix); ok { + if err := writeMatrixError(w, r, v, matrixErr); err != nil { + logvr(v, r).Tag(tagMatrix).Err(err).Debug("Writing Matrix error failed") } + return + } + if isNormalError { + logvr(v, r).Err(httpErr).Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code) + } else { + logvr(v, r).Err(httpErr).Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code) } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests @@ -408,24 +345,14 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, } func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { - if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.WebRoot == "/" { - return s.ensureWebEnabled(s.handleRoot)(w, r, v) + if r.Method == http.MethodGet && r.URL.Path == "/" { + return s.ensureWebEnabled(s.handleHome)(w, r, v) } else if r.Method == http.MethodHead && r.URL.Path == "/" { return s.ensureWebEnabled(s.handleEmpty)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath { return s.handleHealth(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath { - return s.ensureAdmin(s.handleUsersGet)(w, r, v) - } else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath { - return s.ensureAdmin(s.handleUsersAdd)(w, r, v) - } else if r.Method == http.MethodDelete && r.URL.Path == apiUsersPath { - return s.ensureAdmin(s.handleUsersDelete)(w, r, v) - } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == apiUsersAccessPath { - return s.ensureAdmin(s.handleAccessAllow)(w, r, v) - } else if r.Method == http.MethodDelete && r.URL.Path == apiUsersAccessPath { - return s.ensureAdmin(s.handleAccessReset)(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPath { return s.ensureUserManager(s.handleAccountCreate)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiAccountPath { @@ -464,20 +391,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath { return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe! - } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhoneVerifyPath { - return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberVerify)))(w, r, v) - } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath { - return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v) - } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath { - return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath { - return s.handleStats(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath { + } else if r.Method == http.MethodGet && r.URL.Path == apiTiers { return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { return s.handleMatrixDiscovery(w) - } else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil { - return s.handleMetrics(w, r, v) } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { @@ -487,13 +404,13 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit } else if r.Method == http.MethodOptions { return s.limitRequests(s.handleOptions)(w, r, v) // Should work even if the web app is not enabled, see #598 } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" { - return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v) + return s.limitRequests(s.transformBodyJSON(s.authorizeTopicWrite(s.handlePublish)))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath { - return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v) + return s.limitRequests(s.transformMatrixJSON(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) { - return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) + return s.limitRequests(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { - return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) + return s.limitRequests(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) { return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeJSON))(w, r, v) } else if r.Method == http.MethodGet && ssePathRegex.MatchString(r.URL.Path) { @@ -510,8 +427,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return errHTTPNotFound } -func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request, v *visitor) error { - r.URL.Path = webAppIndex +func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.config.WebRootIsApp { + r.URL.Path = webAppIndex + } else { + r.URL.Path = webHomeIndex + } return s.handleStatic(w, r, v) } @@ -543,16 +464,17 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor } func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + appRoot := "/" + if !s.config.WebRootIsApp { + appRoot = "/app" + } response := &apiConfigResponse{ BaseURL: "", // Will translate to window.location.origin - AppRoot: s.config.WebRoot, + AppRoot: appRoot, EnableLogin: s.config.EnableLogin, EnableSignup: s.config.EnableSignup, EnablePayments: s.config.StripeSecretKey != "", - EnableCalls: s.config.TwilioAccount != "", - EnableEmails: s.config.SMTPSenderFrom != "", EnableReservations: s.config.EnableReservations, - BillingContact: s.config.BillingContact, DisallowedTopics: s.config.DisallowedTopics, } b, err := json.MarshalIndent(response, "", " ") @@ -564,41 +486,17 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi return err } -// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set, -// and listen-metrics-http is not set. -func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error { - s.metricsHandler.ServeHTTP(w, r) - return nil -} - -// handleStatic returns all static resources (excluding the docs), including the web app func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error { r.URL.Path = webSiteDir + r.URL.Path util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r) return nil } -// handleDocs returns static resources related to the docs func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error { util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r) return nil } -// handleStats returns the publicly available server stats -func (s *Server) handleStats(w http.ResponseWriter, _ *http.Request, _ *visitor) error { - s.mu.RLock() - messages, n, rate := s.messages, len(s.messagesHistory), float64(0) - if n > 1 { - rate = float64(s.messagesHistory[n-1]-s.messagesHistory[0]) / (float64(n-1) * s.config.ManagerInterval.Seconds()) - } - s.mu.RUnlock() - response := &apiStatsResponse{ - Messages: messages, - MessagesRate: rate, - } - return s.writeJSON(w, response) -} - // handleFile processes the download of attachment files. The method handles GET and HEAD requests against a file. // Before streaming the file to a client, it locates uploader (m.Sender or m.User) in the message cache, so it // can associate the download bandwidth with the uploader. @@ -614,10 +512,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) file := filepath.Join(s.config.AttachmentCacheDir, messageID) stat, err := os.Stat(file) if err != nil { - return errHTTPNotFound.Fields(log.Context{ - "message_id": messageID, - "error_context": "filesystem", - }) + return errHTTPNotFound } w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) @@ -638,10 +533,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) }, s.config.CacheBatchTimeout, 100*time.Millisecond, 300*time.Millisecond, 600*time.Millisecond) } if err != nil { - return errHTTPNotFound.Fields(log.Context{ - "message_id": messageID, - "error_context": "message_cache", - }) + return errHTTPNotFound } } else if err != nil { return err @@ -657,7 +549,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) bandwidthVisitor = s.visitor(m.Sender, nil) } if !bandwidthVisitor.BandwidthAllowed(stat.Size()) { - return errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m) + return errHTTPTooManyRequestsLimitAttachmentBandwidth } // Actually send file f, err := os.Open(file) @@ -665,9 +557,6 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) return err } defer f.Close() - if m.Attachment.Name != "" { - w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(m.Attachment.Name)) - } _, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f) return err } @@ -679,52 +568,29 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error { return writeMatrixDiscoveryResponse(w) } -func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, error) { - start := time.Now() - t, err := fromContext[*topic](r, contextTopic) +func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) { + t, err := s.topicFromPath(r.URL.Path) if err != nil { return nil, err } - vrate, err := fromContext[*visitor](r, contextRateVisitor) - if err != nil { - return nil, err + if !v.MessageAllowed() { + return nil, errHTTPTooManyRequestsLimitMessages } body, err := util.Peek(r.Body, s.config.MessageLimit) if err != nil { return nil, err } m := newDefaultMessage(t.ID, "") - cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m) - if e != nil { - return nil, e.With(t) - } - if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil { - // UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see - // Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove - // the subscription as invalid if any 400-499 code (except 429/408) is returned. - // See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46 - return nil, errHTTPInsufficientStorageUnifiedPush.With(t) - } else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() { - return nil, errHTTPTooManyRequestsLimitMessages.With(t) - } else if email != "" && !vrate.EmailAllowed() { - return nil, errHTTPTooManyRequestsLimitEmails.With(t) - } else if call != "" { - var httpErr *errHTTP - call, httpErr = s.convertPhoneNumber(v.User(), call) - if httpErr != nil { - return nil, httpErr.With(t) - } else if !vrate.CallAllowed() { - return nil, errHTTPTooManyRequestsLimitCalls.With(t) - } + cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m) + if err != nil { + return nil, err } if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) } m.Sender = v.IP() m.User = v.MaybeUserID() - if cache { - m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() - } + m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil { return nil, err } @@ -734,13 +600,11 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e delayed := m.Time > time.Now().Unix() ev := logvrm(v, r, m). Tag(tagPublish). - With(t). Fields(log.Context{ "message_delayed": delayed, "message_firebase": firebase, "message_unifiedpush": unifiedpush, "message_email": email, - "message_call": call, }) if ev.IsTrace() { ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message") @@ -757,10 +621,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e if s.smtpSender != nil && email != "" { go s.sendEmail(v, m, email) } - if s.config.TwilioAccount != "" && call != "" { - go s.callPhone(v, r, m, call) - } - if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream + if s.config.UpstreamBaseURL != "" { go s.forwardPollRequest(v, m) } } else { @@ -779,70 +640,41 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e s.mu.Lock() s.messages++ s.mu.Unlock() - if unifiedpush { - minc(metricUnifiedPushPublishedSuccess) - } - mset(metricMessagePublishDurationMillis, time.Since(start).Milliseconds()) return m, nil } func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { - m, err := s.handlePublishInternal(r, v) + m, err := s.handlePublishWithoutResponse(r, v) if err != nil { - minc(metricMessagesPublishedFailure) return err } - minc(metricMessagesPublishedSuccess) return s.writeJSON(w, m) } func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { - _, err := s.handlePublishInternal(r, v) + _, err := s.handlePublishWithoutResponse(r, v) if err != nil { - minc(metricMessagesPublishedFailure) - minc(metricMatrixPublishedFailure) - if e, ok := err.(*errHTTP); ok && e.HTTPCode == errHTTPInsufficientStorageUnifiedPush.HTTPCode { - topic, err := fromContext[*topic](r, contextTopic) - if err != nil { - return err - } - pushKey, err := fromContext[string](r, contextMatrixPushKey) - if err != nil { - return err - } - if time.Since(topic.LastAccess()) > matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter { - return writeMatrixResponse(w, pushKey) - } - } - return err + return &errMatrix{pushKey: r.Header.Get(matrixPushKeyHeader), err: err} } - minc(metricMessagesPublishedSuccess) - minc(metricMatrixPublishedSuccess) return writeMatrixSuccess(w) } func (s *Server) sendToFirebase(v *visitor, m *message) { logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") if err := s.firebaseClient.Send(v, m); err != nil { - minc(metricFirebasePublishedFailure) if err == errFirebaseTemporarilyBanned { logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error()) } else { logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error()) } - return } - minc(metricFirebasePublishedSuccess) } func (s *Server) sendEmail(v *visitor, m *message, email string) { logvm(v, m).Tag(tagEmail).Field("email", email).Debug("Sending email to %s", email) if err := s.smtpSender.Send(v, m, email); err != nil { logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error()) - minc(metricEmailsPublishedFailure) - return } - minc(metricEmailsPublishedSuccess) } func (s *Server) forwardPollRequest(v *visitor, m *message) { @@ -855,11 +687,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { logvm(v, m).Err(err).Warn("Unable to publish poll request") return } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) req.Header.Set("X-Poll-ID", m.ID) - if s.config.UpstreamAccessToken != "" { - req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken)) - } var httpClient = &http.Client{ Timeout: time.Second * 10, } @@ -868,16 +696,12 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { logvm(v, m).Err(err).Warn("Unable to publish poll request") return } else if response.StatusCode != http.StatusOK { - if response.StatusCode == http.StatusTooManyRequests { - logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s; you may solve this by sending fewer daily messages, or by configuring upstream-access-token (assuming you have an account with higher rate limits) ", s.config.UpstreamBaseURL, response.Status) - } else { - logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s", s.config.UpstreamBaseURL, response.Status) - } + logvm(v, m).Err(err).Warn("Unable to publish poll request, unexpected HTTP status: %d", response.StatusCode) return } } -func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) { +func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) { cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = readParam(r, "x-title", "title", "t") @@ -893,7 +717,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if attach != "" { if !urlRegex.MatchString(attach) { - return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid + return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach if m.Attachment.Name == "" { @@ -911,56 +735,57 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if icon != "" { if !urlRegex.MatchString(icon) { - return false, false, "", "", false, errHTTPBadRequestIconURLInvalid + return false, false, "", false, errHTTPBadRequestIconURLInvalid } m.Icon = icon } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") - if s.smtpSender == nil && email != "" { - return false, false, "", "", false, errHTTPBadRequestEmailDisabled + if email != "" { + if !v.EmailAllowed() { + return false, false, "", false, errHTTPTooManyRequestsLimitEmails + } } - call = readParam(r, "x-call", "call") - if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { - return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled - } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { - return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid + if s.smtpSender == nil && email != "" { + return false, false, "", false, errHTTPBadRequestEmailDisabled } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") if messageStr != "" { m.Message = messageStr } - var e error - m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) - if e != nil { - return false, false, "", "", false, errHTTPBadRequestPriorityInvalid + m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) + if err != nil { + return false, false, "", false, errHTTPBadRequestPriorityInvalid + } + tagsStr := readParam(r, "x-tags", "tags", "tag", "ta") + if tagsStr != "" { + m.Tags = make([]string, 0) + for _, s := range util.SplitNoEmpty(tagsStr, ",") { + m.Tags = append(m.Tags, strings.TrimSpace(s)) + } } - m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") if delayStr != "" { if !cache { - return false, false, "", "", false, errHTTPBadRequestDelayNoCache + return false, false, "", false, errHTTPBadRequestDelayNoCache } if email != "" { - return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) - } - if call != "" { - return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) + return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { - return false, false, "", "", false, errHTTPBadRequestDelayCannotParse + return false, false, "", false, errHTTPBadRequestDelayCannotParse } else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() { - return false, false, "", "", false, errHTTPBadRequestDelayTooSmall + return false, false, "", false, errHTTPBadRequestDelayTooSmall } else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() { - return false, false, "", "", false, errHTTPBadRequestDelayTooLarge + return false, false, "", false, errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() } actionsStr := readParam(r, "x-actions", "actions", "action") if actionsStr != "" { - m.Actions, e = parseActions(actionsStr) - if e != nil { - return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) + m.Actions, err = parseActions(actionsStr) + if err != nil { + return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error()) } } unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! @@ -974,7 +799,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi cache = false email = "" } - return cache, firebase, email, call, unifiedpush, nil + return cache, firebase, email, unifiedpush, nil } // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. @@ -1024,7 +849,7 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error { if !utf8.Valid(body.PeekedBytes) { - return errHTTPBadRequestMessageNotUTF8.With(m) + return errHTTPBadRequestMessageNotUTF8 } if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!) m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required @@ -1037,7 +862,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error { if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" { - return errHTTPBadRequestAttachmentsDisallowed.With(m) + return errHTTPBadRequestAttachmentsDisallowed } vinfo, err := v.Info() if err != nil { @@ -1045,17 +870,13 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, } attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix() if m.Time > attachmentExpiry { - return errHTTPBadRequestAttachmentsExpiryBeforeDelivery.With(m) + return errHTTPBadRequestAttachmentsExpiryBeforeDelivery } contentLengthStr := r.Header.Get("Content-Length") if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64) if err == nil && (contentLength > vinfo.Stats.AttachmentTotalSizeRemaining || contentLength > vinfo.Limits.AttachmentFileSizeLimit) { - return errHTTPEntityTooLargeAttachment.With(m).Fields(log.Context{ - "message_content_length": contentLength, - "attachment_total_size_remaining": vinfo.Stats.AttachmentTotalSizeRemaining, - "attachment_file_size_limit": vinfo.Limits.AttachmentFileSizeLimit, - }) + return errHTTPEntityTooLargeAttachment } } if m.Attachment == nil { @@ -1078,7 +899,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, } m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...) if err == util.ErrLimitReached { - return errHTTPEntityTooLargeAttachment.With(m) + return errHTTPEntityTooLargeAttachment } else if err != nil { return err } @@ -1131,7 +952,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * if err != nil { return err } - poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r) + poll, since, scheduled, filters, err := parseSubscribeParams(r) if err != nil { return err } @@ -1161,15 +982,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * } return nil } - if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil { - return err - } w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset! if poll { - for _, t := range topics { - t.Keepalive() - } return s.sendOldMessages(topics, since, scheduled, v, sub) } ctx, cancel := context.WithCancel(context.Background()) @@ -1196,16 +1011,8 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * case <-r.Context().Done(): return nil case <-time.After(s.config.KeepaliveInterval): - ev := logvr(v, r).Tag(tagSubscribe) - if len(topics) == 1 { - ev.With(topics[0]).Trace("Sending keepalive message to %s", topics[0].ID) - } else { - ev.Trace("Sending keepalive message to %d topics", len(topics)) - } + logvr(v, r).Tag(tagSubscribe).Trace("Sending keepalive message") v.Keepalive() - for _, t := range topics { - t.Keepalive() - } if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message return err } @@ -1227,7 +1034,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi if err != nil { return err } - poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r) + poll, since, scheduled, filters, err := parseSubscribeParams(r) if err != nil { return err } @@ -1244,7 +1051,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi } defer conn.Close() - // Subscription connections can be canceled externally, see topic.CancelSubscribersExceptUser + // Subscription connections can be canceled externally, see topic.CancelSubscribers cancelCtx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1293,9 +1100,6 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi return &websocket.CloseError{Code: websocket.CloseNormalClosure, Text: "subscription was canceled"} case <-time.After(s.config.KeepaliveInterval): v.Keepalive() - for _, t := range topics { - t.Keepalive() - } if err := ping(); err != nil { return err } @@ -1313,14 +1117,8 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi } return conn.WriteJSON(msg) } - if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil { - return err - } w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests if poll { - for _, t := range topics { - t.Keepalive() - } return s.sendOldMessages(topics, since, scheduled, v, sub) } subscriberIDs := make([]int, 0) @@ -1346,7 +1144,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi return err } -func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, rateTopics []string, err error) { +func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) { poll = readBoolParam(r, false, "x-poll", "poll", "po") scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched") since, err = parseSince(r, poll) @@ -1357,73 +1155,9 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu if err != nil { return } - rateTopics = readCommaSeparatedParam(r, "x-rate-topics", "rate-topics") return } -// maybeSetRateVisitors sets the rate visitor on a topic (v.SetRateVisitor), indicating that all messages published -// to that topic will be rate limited against the rate visitor instead of the publishing visitor. -// -// Setting the rate visitor is ony allowed if the `visitor-subscriber-rate-limiting` setting is enabled, AND -// - auth-file is not set (everything is open by default) -// - or the topic is reserved, and v.user is the owner -// - or the topic is not reserved, and v.user has write access -// -// Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition -// until the Android app will send the "Rate-Topics" header. -func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error { - // Bail out if not enabled - if !s.config.VisitorSubscriberRateLimiting { - return nil - } - - // Make a list of topics that we'll actually set the RateVisitor on - eligibleRateTopics := make([]*topic, 0) - for _, t := range topics { - if (strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength) || util.Contains(rateTopics, t.ID) { - eligibleRateTopics = append(eligibleRateTopics, t) - } - } - if len(eligibleRateTopics) == 0 { - return nil - } - - // If access controls are turned off, v has access to everything, and we can set the rate visitor - if s.userManager == nil { - return s.setRateVisitors(r, v, eligibleRateTopics) - } - - // If access controls are enabled, only set rate visitor if - // - topic is reserved, and v.user is the owner - // - topic is not reserved, and v.user has write access - writableRateTopics := make([]*topic, 0) - for _, t := range topics { - ownerUserID, err := s.userManager.ReservationOwner(t.ID) - if err != nil { - return err - } - if ownerUserID == "" { - if err := s.userManager.Authorize(v.User(), t.ID, user.PermissionWrite); err == nil { - writableRateTopics = append(writableRateTopics, t) - } - } else if ownerUserID == v.MaybeUserID() { - writableRateTopics = append(writableRateTopics, t) - } - } - return s.setRateVisitors(r, v, writableRateTopics) -} - -func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topic) error { - for _, t := range rateTopics { - logvr(v, r). - Tag(tagSubscribe). - With(t). - Debug("Setting visitor as rate visitor for topic %s", t.ID) - t.SetRateVisitor(v) - } - return nil -} - // sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the // marker, returning only messages that are newer than the marker. func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error { @@ -1486,7 +1220,6 @@ func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visito return nil } -// topicFromPath returns the topic from a root path (e.g. /mytopic), creating it if it doesn't exist. func (s *Server) topicFromPath(path string) (*topic, error) { parts := strings.Split(path, "/") if len(parts) < 2 { @@ -1495,7 +1228,6 @@ func (s *Server) topicFromPath(path string) (*topic, error) { return s.topicFromID(parts[1]) } -// topicsFromPath returns the topic from a root path (e.g. /mytopic,mytopic2), creating it if it doesn't exist. func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { parts := strings.Split(path, "/") if len(parts) < 2 { @@ -1509,7 +1241,6 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { return topics, parts[1], nil } -// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist. func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { s.mu.Lock() defer s.mu.Unlock() @@ -1529,7 +1260,6 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { return topics, nil } -// topicFromID returns the topic with the given ID, creating it if it doesn't exist. func (s *Server) topicFromID(id string) (*topic, error) { topics, err := s.topicsFromIDs(id) if err != nil { @@ -1538,23 +1268,6 @@ func (s *Server) topicFromID(id string) (*topic, error) { return topics[0], nil } -// topicsFromPattern returns a list of topics matching the given pattern, but it does not create them. -func (s *Server) topicsFromPattern(pattern string) ([]*topic, error) { - s.mu.RLock() - defer s.mu.RUnlock() - patternRegexp, err := regexp.Compile("^" + strings.ReplaceAll(pattern, "*", ".*") + "$") - if err != nil { - return nil, err - } - topics := make([]*topic, 0) - for _, t := range s.topics { - if patternRegexp.MatchString(t.ID) { - topics = append(topics, t) - } - } - return topics, nil -} - func (s *Server) runSMTPServer() error { s.smtpServerBackend = newMailBackend(s.config, s.handle) s.smtpServer = smtp.NewServer(s.smtpServerBackend) @@ -1624,14 +1337,8 @@ func (s *Server) runFirebaseKeepaliver() { select { case <-time.After(s.config.FirebaseKeepaliveInterval): s.sendToFirebase(v, newKeepaliveMessage(firebaseControlTopic)) - /* - FIXME: Disable iOS polling entirely for now due to thundering herd problem (see #677) - To solve this, we'd have to shard the iOS poll topics to spread out the polling evenly. - Given that it's not really necessary to poll, turning it off for now should not have any impact. - - case <-time.After(s.config.FirebasePollInterval): - s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic)) - */ + case <-time.After(s.config.FirebasePollInterval): + s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic)) case <-s.closeChan: return } @@ -1659,7 +1366,7 @@ func (s *Server) sendDelayedMessages() error { for _, m := range messages { var u *user.User if s.userManager != nil && m.User != "" { - u, err = s.userManager.UserByID(m.User) + u, err = s.userManager.User(m.User) if err != nil { log.With(m).Err(err).Warn("Error sending delayed message") continue @@ -1675,9 +1382,9 @@ func (s *Server) sendDelayedMessages() error { func (s *Server) sendDelayedMessage(v *visitor, m *message) error { logvm(v, m).Debug("Sending delayed message") - s.mu.RLock() + s.mu.Lock() t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published - s.mu.RUnlock() + s.mu.Unlock() if ok { go func() { // We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler @@ -1748,9 +1455,6 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { if m.Delay != "" { r.Header.Set("X-Delay", m.Delay) } - if m.Call != "" { - r.Header.Set("X-Call", m.Call) - } return next(w, r, v) } } @@ -1759,15 +1463,11 @@ func (s *Server) transformMatrixJSON(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit) if err != nil { - logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request") - if e, ok := err.(*errMatrixPushkeyRejected); ok { - return writeMatrixResponse(w, e.rejectedPushKey) - } + logvr(v, r).Tag(tagMatrix).Err(err).Trace("Invalid Matrix request") return err } if err := next(w, newRequest, v); err != nil { - logvr(v, r).Tag(tagMatrix).Err(err).Debug("Error handling Matrix request") - return err + return &errMatrix{pushKey: newRequest.Header.Get(matrixPushKeyHeader), err: err} } return nil } @@ -1793,8 +1493,8 @@ func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc u := v.User() for _, t := range topics { if err := s.userManager.Authorize(u, t.ID, perm); err != nil { - logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID) - return errHTTPForbidden.With(t) + logvr(v, r).Err(err).Field("message_topic", t.ID).Debug("Access to topic %s not authorized", t.ID) + return errHTTPForbidden } } return next(w, r, v) @@ -1804,9 +1504,7 @@ func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc // maybeAuthenticate reads the "Authorization" header and will try to authenticate the user // if it is set. // -// - If auth-file is not configured, immediately return an IP-based visitor -// - If the header is not set or not supported (anything non-Basic and non-Bearer), -// an IP-based visitor is returned +// - If the header is not set, an IP-based visitor is returned // - If the header is set, authenticate will be called to check the username/password (Basic auth), // or the token (Bearer auth), and read the user from the database // @@ -1816,14 +1514,13 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) { // Read "Authorization" header value, and exit out early if it's not set ip := extractIPAddress(r, s.config.BehindProxy) vip := s.visitor(ip, nil) - if s.userManager == nil { - return vip, nil - } header, err := readAuthHeader(r) if err != nil { return vip, err - } else if !supportedAuthHeader(header) { + } else if header == "" { return vip, nil + } else if s.userManager == nil { + return vip, errHTTPUnauthorized } // If we're trying to auth, check the rate limiter first if !vip.AuthAllowed() { @@ -1865,14 +1562,6 @@ func readAuthHeader(r *http.Request) (string, error) { return value, nil } -// supportedAuthHeader returns true only if the Authorization header value starts -// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither -// are things like "WebPush", or "vapid" (see #629). -func supportedAuthHeader(value string) bool { - value = strings.ToLower(value) - return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ") -} - func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) { r.Header.Set("Authorization", value) username, password, ok := r.BasicAuth() @@ -1919,17 +1608,3 @@ func (s *Server) writeJSON(w http.ResponseWriter, v any) error { } return nil } - -func (s *Server) updateAndWriteStats(messagesCount int64) { - s.mu.Lock() - s.messagesHistory = append(s.messagesHistory, messagesCount) - if len(s.messagesHistory) > messagesHistoryMax { - s.messagesHistory = s.messagesHistory[1:] - } - s.mu.Unlock() - go func() { - if err := s.messageCache.UpdateStats(messagesCount); err != nil { - log.Tag(tagManager).Err(err).Warn("Cannot write messages stats") - } - }() -} diff --git a/server/server.yml b/server/server.yml index 9c7972e..91ae8b0 100644 --- a/server/server.yml +++ b/server/server.yml @@ -117,19 +117,18 @@ # attachment-expiry-duration: "3h" # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set, -# messages will additionally be sent out as e-mail using an external SMTP server. -# -# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported. -# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst). +# messages will additionally be sent out as e-mail using an external SMTP server. As of today, only +# SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings +# below (visitor-email-limit-burst & visitor-email-limit-burst). # # - smtp-sender-addr is the hostname:port of the SMTP server +# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user # - smtp-sender-from is the e-mail address of the sender -# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth) # # smtp-sender-addr: -# smtp-sender-from: # smtp-sender-user: # smtp-sender-pass: +# smtp-sender-from: # If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send # emails to a topic e-mail address to publish messages to a topic. @@ -144,18 +143,6 @@ # smtp-server-domain: # smtp-server-addr-prefix: -# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header. -# -# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 -# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586 -# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586 -# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 -# -# twilio-account: -# twilio-auth-token: -# twilio-phone-number: -# twilio-verify-service: - # Interval in which keepalive messages are sent to the client. This is to prevent # intermediaries closing the connection for inactivity. # @@ -179,13 +166,11 @@ # # disallowed-topics: -# Defines the root path of the web app, or disables the web app entirely. +# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the +# web app. If you self-host, you don't want to change this. +# Can be "app" (default), "home" or "disable" to disable the web app entirely. # -# Can be any simple path, e.g. "/", "/app", or "/ntfy". For backwards-compatibility reasons, -# the values "app" (maps to "/"), "home" (maps to "/app"), or "disable" (maps to "") to disable -# the web app entirely. -# -# web-root: / +# web-root: app # Various feature flags used to control the web app, and API access, mainly around user and # account management. @@ -208,12 +193,7 @@ # the message ID of the original message, instructing the iOS app to poll this server for the actual message contents. # This is to prevent the upstream server and Firebase/APNS from being able to read the message. # -# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh". -# - upstream-access-token is the token used to authenticate with the upstream server. This is only required -# if you exceed the upstream rate limits, or the uptream server requires authentication. -# # upstream-base-url: -# upstream-access-token: # Rate limiting: Total number of topics before the server rejects new topics. # @@ -254,54 +234,15 @@ # visitor-attachment-total-size-limit: "100M" # visitor-attachment-daily-bandwidth-limit: "500M" -# Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush) -# -# If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed -# to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume -# publishers (e.g. Matrix/Mastodon servers) are allowed to send. -# -# Once enabled, a client may send a "Rate-Topics: ,,..." header when subscribing to topics via -# HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits -# to use when publishing on this topic. Note: Setting the rate visitor requires READ-WRITE permission on the topic. -# -# UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if -# no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit". -# -# visitor-subscriber-rate-limiting: false - # Payments integration via Stripe # # - stripe-secret-key is the key used for the Stripe API communication. Setting this values # enables payments in the ntfy web app (e.g. Upgrade dialog). See https://dashboard.stripe.com/apikeys. # - stripe-webhook-key is the key required to validate the authenticity of incoming webhooks from Stripe. # Webhooks are essential up keep the local database in sync with the payment provider. See https://dashboard.stripe.com/webhooks. -# - billing-contact is an email address or website displayed in the "Upgrade tier" dialog to let people reach -# out with billing questions. If unset, nothing will be displayed. # # stripe-secret-key: # stripe-webhook-key: -# billing-contact: - -# Metrics -# -# ntfy can expose Prometheus-style metrics via a /metrics endpoint, or on a dedicated listen IP/port. -# Metrics may be considered sensitive information, so before you enable them, be sure you know what you are -# doing, and/or secure access to the endpoint in your reverse proxy. -# -# - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket) -# - metrics-listen-http exposes the metrics endpoint via a dedicated [IP]:port. If set, this option implicitly -# enables metrics as well, e.g. "10.0.1.1:9090" or ":9090" -# -# enable-metrics: false -# metrics-listen-http: - -# Profiling -# -# ntfy can expose Go's net/http/pprof endpoints to support profiling of the ntfy server. If enabled, ntfy will listen -# on a dedicated listen IP/port, which can be accessed via the web browser on http://:/debug/pprof/. -# This can be helpful to expose bottlenecks, and visualize call flows. See https://pkg.go.dev/net/http/pprof for details. -# -# profile-listen-http: # Logging options # diff --git a/server/server_account.go b/server/server_account.go index 6e6a686..aff9f1b 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -56,7 +56,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis Messages: limits.MessageLimit, MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()), Emails: limits.EmailLimit, - Calls: limits.CallLimit, Reservations: limits.ReservationsLimit, AttachmentTotalSize: limits.AttachmentTotalSizeLimit, AttachmentFileSize: limits.AttachmentFileSizeLimit, @@ -68,8 +67,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis MessagesRemaining: stats.MessagesRemaining, Emails: stats.Emails, EmailsRemaining: stats.EmailsRemaining, - Calls: stats.Calls, - CallsRemaining: stats.CallsRemaining, Reservations: stats.Reservations, ReservationsRemaining: stats.ReservationsRemaining, AttachmentTotalSize: stats.AttachmentTotalSize, @@ -103,24 +100,21 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis Customer: true, Subscription: u.Billing.StripeSubscriptionID != "", Status: string(u.Billing.StripeSubscriptionStatus), - Interval: string(u.Billing.StripeSubscriptionInterval), PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(), CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(), } } - if s.config.EnableReservations { - reservations, err := s.userManager.Reservations(u.Name) - if err != nil { - return err - } - if len(reservations) > 0 { - response.Reservations = make([]*apiAccountReservation, 0) - for _, r := range reservations { - response.Reservations = append(response.Reservations, &apiAccountReservation{ - Topic: r.Topic, - Everyone: r.Everyone.String(), - }) - } + reservations, err := s.userManager.Reservations(u.Name) + if err != nil { + return err + } + if len(reservations) > 0 { + response.Reservations = make([]*apiAccountReservation, 0) + for _, r := range reservations { + response.Reservations = append(response.Reservations, &apiAccountReservation{ + Topic: r.Topic, + Everyone: r.Everyone.String(), + }) } } tokens, err := s.userManager.Tokens(u.ID) @@ -143,15 +137,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis }) } } - if s.config.TwilioAccount != "" { - phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) - if err != nil { - return err - } - if len(phoneNumbers) > 0 { - response.PhoneNumbers = phoneNumbers - } - } } else { response.Username = user.Everyone response.Role = string(user.RoleAnonymous) @@ -458,7 +443,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ if err != nil { return err } - t.CancelSubscribersExceptUser(u.ID) + t.CancelSubscribers(u.ID) return s.writeJSON(w, newSuccessResponse()) } @@ -521,76 +506,9 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi if err := s.messageCache.ExpireMessages(topics...); err != nil { return err } - go s.pruneMessages() return nil } -func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() - req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false) - if err != nil { - return err - } else if !phoneNumberRegex.MatchString(req.Number) { - return errHTTPBadRequestPhoneNumberInvalid - } else if req.Channel != "sms" && req.Channel != "call" { - return errHTTPBadRequestPhoneNumberVerifyChannelInvalid - } - // Check user is allowed to add phone numbers - if u == nil || (u.IsUser() && u.Tier == nil) { - return errHTTPUnauthorized - } else if u.IsUser() && u.Tier.CallLimit == 0 { - return errHTTPUnauthorized - } - // Check if phone number exists - phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) - if err != nil { - return err - } else if util.Contains(phoneNumbers, req.Number) { - return errHTTPConflictPhoneNumberExists - } - // Actually add the unverified number, and send verification - logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification") - if err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil { - return err - } - return s.writeJSON(w, newSuccessResponse()) -} - -func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() - req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) - if err != nil { - return err - } - if !phoneNumberRegex.MatchString(req.Number) { - return errHTTPBadRequestPhoneNumberInvalid - } - if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil { - return err - } - logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified") - if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil { - return err - } - return s.writeJSON(w, newSuccessResponse()) -} - -func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() - req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) - if err != nil { - return err - } - if !phoneNumberRegex.MatchString(req.Number) { - return errHTTPBadRequestPhoneNumberInvalid - } - logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number") - if err := s.userManager.RemovePhoneNumber(u.ID, req.Number); err != nil { - return err - } - return s.writeJSON(w, newSuccessResponse()) -} - // publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic func (s *Server) publishSyncEventAsync(v *visitor) { go func() { diff --git a/server/server_account_test.go b/server/server_account_test.go index 119efb1..0290303 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -151,8 +151,6 @@ func TestAccount_Get_Anonymous(t *testing.T) { require.Equal(t, int64(1004), account.Stats.MessagesRemaining) require.Equal(t, int64(0), account.Stats.Emails) require.Equal(t, int64(24), account.Stats.EmailsRemaining) - require.Equal(t, int64(0), account.Stats.Calls) - require.Equal(t, int64(0), account.Stats.CallsRemaining) rr = request(t, s, "POST", "/mytopic", "", nil) require.Equal(t, 200, rr.Code) @@ -292,7 +290,6 @@ func TestAccount_ChangePassword_NoAccount(t *testing.T) { } func TestAccount_ExtendToken(t *testing.T) { - t.Parallel() s := newTestServer(t, newTestConfigWithAuthFile(t)) defer s.closeDatabases() @@ -500,8 +497,6 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) { func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { conf := newTestConfigWithAuthFile(t) conf.EnableSignup = true - conf.EnableReservations = true - conf.TwilioAccount = "dummy" s := newTestServer(t, conf) // Create user @@ -514,7 +509,6 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { MessageLimit: 123, MessageExpiryDuration: 86400 * time.Second, EmailLimit: 32, - CallLimit: 10, ReservationLimit: 2, AttachmentFileSizeLimit: 1231231, AttachmentTotalSizeLimit: 123123, @@ -556,7 +550,6 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { require.Equal(t, int64(123), account.Limits.Messages) require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration) require.Equal(t, int64(32), account.Limits.Emails) - require.Equal(t, int64(10), account.Limits.Calls) require.Equal(t, int64(2), account.Limits.Reservations) require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize) require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize) @@ -618,7 +611,6 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) { } func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { - t.Parallel() conf := newTestConfigWithAuthFile(t) conf.AuthDefault = user.PermissionReadWrite s := newTestServer(t, conf) @@ -663,17 +655,6 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { m2 := toMessage(t, rr.Body.String()) require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) - // Pre-verify message count and file - ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(ms)) - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) - - ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false) - require.Nil(t, err) - require.Equal(t, 1, len(ms)) - require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) - // Delete reservation rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{ "X-Delete-Messages": "true", @@ -689,13 +670,9 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { // Verify that messages and attachments were deleted // This does not explicitly call the manager! - waitFor(t, func() bool { - ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) - require.Nil(t, err) - return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID)) - }) + time.Sleep(time.Second) - ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false) + ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) require.Nil(t, err) require.Equal(t, 0, len(ms)) require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) @@ -707,10 +684,91 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) } -/*func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) { +func TestAccount_Reservation_Add_Kills_Other_Subscribers(t *testing.T) { conf := newTestConfigWithAuthFile(t) conf.AuthDefault = user.PermissionReadWrite - conf.AuthStatsQueueWriterInterval = 300 * time.Millisecond + conf.EnableSignup = true + s := newTestServer(t, conf) + defer s.closeDatabases() + + // Create user with tier + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 20, + ReservationLimit: 2, + })) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + + // Subscribe anonymously + anonCh, userCh := make(chan bool), make(chan bool) + go func() { + rr := request(t, s, "GET", "/mytopic/json", ``, nil) // This blocks until it's killed! + require.Equal(t, 200, rr.Code) + messages := toMessages(t, rr.Body.String()) + require.Equal(t, 2, len(messages)) // This is the meat. We should NOT receive the second message! + require.Equal(t, "open", messages[0].Event) + require.Equal(t, "message before reservation", messages[1].Message) + anonCh <- true + log.Info("Anonymous subscription ended") + }() + + // Subscribe with user + go func() { + rr := request(t, s, "GET", "/mytopic/json", ``, map[string]string{ // Blocks! + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + messages := toMessages(t, rr.Body.String()) + require.Equal(t, 3, len(messages)) + require.Equal(t, "open", messages[0].Event) + require.Equal(t, "message before reservation", messages[1].Message) + require.Equal(t, "message after reservation", messages[2].Message) + userCh <- true + log.Info("User subscription ended") + }() + + // Publish message (before reservation) + time.Sleep(2 * time.Second) // Wait for subscribers + rr = request(t, s, "POST", "/mytopic", "message before reservation", nil) + require.Equal(t, 200, rr.Code) + time.Sleep(2 * time.Second) // Wait for subscribers to receive message + + // Reserve a topic + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Everyone but phil should be killed + select { + case <-anonCh: + case <-time.After(5 * time.Second): + t.Fatal("Waiting for anonymous subscription to be killed failed") + } + + // Publish a message + rr = request(t, s, "POST", "/mytopic", "message after reservation", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + // Kill user Go routine + s.topics["mytopic"].CancelSubscribers("") + + select { + case <-userCh: + case <-time.After(5 * time.Second): + t.Fatal("Waiting for user subscription to be killed failed") + } +} + +func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) { + conf := newTestConfigWithAuthFile(t) + conf.AuthDefault = user.PermissionReadWrite + conf.AuthStatsQueueWriterInterval = 200 * time.Millisecond s := newTestServer(t, conf) defer s.closeDatabases() @@ -732,12 +790,13 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { }) require.Equal(t, 200, rr.Code) - // Wait for stats queue writer, verify that message stats were persisted - waitFor(t, func() bool { - u, err := s.userManager.User("phil") - require.Nil(t, err) - return int64(1) == u.Stats.Messages - }) + // Wait for stats queue writer + time.Sleep(300 * time.Millisecond) + + // Verify that message stats were persisted + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Equal(t, int64(1), u.Stats.Messages) // Change tier, make a request (to reset limiters) require.Nil(t, s.userManager.ChangeTier("phil", "pro")) @@ -755,11 +814,10 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { require.Equal(t, 200, rr.Code) // Verify that message stats were persisted - waitFor(t, func() bool { - u, err := s.userManager.User("phil") - require.Nil(t, err) - return int64(2) == u.Stats.Messages // v.EnqueueUserStats had run! - }) + time.Sleep(300 * time.Millisecond) + u, err = s.userManager.User("phil") + require.Nil(t, err) + require.Equal(t, int64(2), u.Stats.Messages) // v.EnqueueUserStats had run! // Stats keep counting rr = request(t, s, "GET", "/v1/account", "", map[string]string{ @@ -768,4 +826,5 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { require.Equal(t, 200, rr.Code) account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) require.Equal(t, int64(2), account.Stats.Messages) // Is not reset! -}*/ + +} diff --git a/server/server_admin.go b/server/server_admin.go deleted file mode 100644 index 9380a5f..0000000 --- a/server/server_admin.go +++ /dev/null @@ -1,143 +0,0 @@ -package server - -import ( - "heckel.io/ntfy/user" - "net/http" -) - -func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error { - users, err := s.userManager.Users() - if err != nil { - return err - } - grants, err := s.userManager.AllGrants() - if err != nil { - return err - } - usersResponse := make([]*apiUserResponse, len(users)) - for i, u := range users { - tier := "" - if u.Tier != nil { - tier = u.Tier.Code - } - userGrants := make([]*apiUserGrantResponse, len(grants[u.ID])) - for i, g := range grants[u.ID] { - userGrants[i] = &apiUserGrantResponse{ - Topic: g.TopicPattern, - Permission: g.Allow.String(), - } - } - usersResponse[i] = &apiUserResponse{ - Username: u.Name, - Role: string(u.Role), - Tier: tier, - Grants: userGrants, - } - } - return s.writeJSON(w, usersResponse) -} - -func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiUserAddRequest](r.Body, jsonBodyBytesLimit, false) - if err != nil { - return err - } else if !user.AllowedUsername(req.Username) || req.Password == "" { - return errHTTPBadRequest.Wrap("username invalid, or password missing") - } - u, err := s.userManager.User(req.Username) - if err != nil && err != user.ErrUserNotFound { - return err - } else if u != nil { - return errHTTPConflictUserExists - } - var tier *user.Tier - if req.Tier != "" { - tier, err = s.userManager.Tier(req.Tier) - if err == user.ErrTierNotFound { - return errHTTPBadRequestTierInvalid - } else if err != nil { - return err - } - } - if err := s.userManager.AddUser(req.Username, req.Password, user.RoleUser); err != nil { - return err - } - if tier != nil { - if err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil { - return err - } - } - return s.writeJSON(w, newSuccessResponse()) -} - -func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false) - if err != nil { - return err - } - u, err := s.userManager.User(req.Username) - if err == user.ErrUserNotFound { - return errHTTPBadRequestUserNotFound - } else if err != nil { - return err - } else if !u.IsUser() { - return errHTTPUnauthorized.Wrap("can only remove regular users from API") - } - if err := s.userManager.RemoveUser(req.Username); err != nil { - return err - } - if err := s.killUserSubscriber(u, "*"); err != nil { // FIXME super inefficient - return err - } - return s.writeJSON(w, newSuccessResponse()) -} - -func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, jsonBodyBytesLimit, false) - if err != nil { - return err - } - _, err = s.userManager.User(req.Username) - if err == user.ErrUserNotFound { - return errHTTPBadRequestUserNotFound - } else if err != nil { - return err - } - permission, err := user.ParsePermission(req.Permission) - if err != nil { - return errHTTPBadRequestPermissionInvalid - } - if err := s.userManager.AllowAccess(req.Username, req.Topic, permission); err != nil { - return err - } - return s.writeJSON(w, newSuccessResponse()) -} - -func (s *Server) handleAccessReset(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccessResetRequest](r.Body, jsonBodyBytesLimit, false) - if err != nil { - return err - } - u, err := s.userManager.User(req.Username) - if err != nil { - return err - } - if err := s.userManager.ResetAccess(req.Username, req.Topic); err != nil { - return err - } - if err := s.killUserSubscriber(u, req.Topic); err != nil { // This may be a pattern - return err - } - return s.writeJSON(w, newSuccessResponse()) -} - -func (s *Server) killUserSubscriber(u *user.User, topicPattern string) error { - topics, err := s.topicsFromPattern(topicPattern) - if err != nil { - return err - } - for _, t := range topics { - t.CancelSubscriberUser(u.ID) - } - return nil -} diff --git a/server/server_admin_test.go b/server/server_admin_test.go deleted file mode 100644 index 1513ea4..0000000 --- a/server/server_admin_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package server - -import ( - "github.com/stretchr/testify/require" - "heckel.io/ntfy/user" - "heckel.io/ntfy/util" - "sync/atomic" - "testing" - "time" -) - -func TestUser_AddRemove(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() - - // Create admin, tier - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "tier1", - })) - - // Create user via API - rr := request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Create user with tier via API - rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Check users - users, err := s.userManager.Users() - require.Nil(t, err) - require.Equal(t, 4, len(users)) - require.Equal(t, "phil", users[0].Name) - require.Equal(t, "ben", users[1].Name) - require.Equal(t, user.RoleUser, users[1].Role) - require.Nil(t, users[1].Tier) - require.Equal(t, "emma", users[2].Name) - require.Equal(t, user.RoleUser, users[2].Role) - require.Equal(t, "tier1", users[2].Tier.Code) - require.Equal(t, user.Everyone, users[3].Name) - - // Delete user via API - rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) -} - -func TestUser_AddRemove_Failures(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - defer s.closeDatabases() - - // Create admin - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) - - // Cannot create user with invalid username - rr := request(t, s, "PUT", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 400, rr.Code) - - // Cannot create user if user already exists - rr = request(t, s, "PUT", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code) - - // Cannot create user with invalid tier - rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code) - - // Cannot delete user as non-admin - rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 401, rr.Code) - - // Delete user via API - rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) -} - -func TestAccess_AllowReset(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) - defer s.closeDatabases() - - // User and admin - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) - - // Subscribing not allowed - rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 403, rr.Code) - - // Grant access - rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Now subscribing is allowed - rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 200, rr.Code) - - // Reset access - rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Subscribing not allowed (again) - rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 403, rr.Code) -} - -func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) - defer s.closeDatabases() - - // User - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) - - // Grant access fails, because non-admin - rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 401, rr.Code) -} - -func TestAccess_AllowReset_KillConnection(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - s := newTestServer(t, c) - defer s.closeDatabases() - - // User and admin, grant access to "gol*" topics - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) - require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard! - - start, timeTaken := time.Now(), atomic.Int64{} - go func() { - rr := request(t, s, "GET", "/gold/json", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - }) - require.Equal(t, 200, rr.Code) - timeTaken.Store(time.Since(start).Milliseconds()) - }() - time.Sleep(500 * time.Millisecond) - - // Reset access - rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, rr.Code) - - // Wait for connection to be killed; this will fail if the connection is never killed - waitFor(t, func() bool { - return timeTaken.Load() >= 500 - }) -} diff --git a/server/server_manager.go b/server/server_manager.go index 52e3621..2b80ae1 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -2,7 +2,6 @@ package server import ( "heckel.io/ntfy/log" - "heckel.io/ntfy/util" "strings" ) @@ -35,20 +34,16 @@ func (s *Server) execManager() { s.mu.Lock() defer s.mu.Unlock() for _, t := range s.topics { - subs, lastAccess := t.Stats() - ev := log.Tag(tagManager).With(t) - if t.Stale() { - if ev.IsTrace() { - ev.Trace("- topic %s: Deleting stale topic (%d subscribers, accessed %s)", t.ID, subs, util.FormatTime(lastAccess)) - } + subs := t.SubscribersCount() + log.Tag(tagManager).Trace("- topic %s: %d subscribers", t.ID, subs) + msgs, exists := messageCounts[t.ID] + if subs == 0 && (!exists || msgs == 0) { + log.Tag(tagManager).Trace("Deleting empty topic %s", t.ID) emptyTopics++ delete(s.topics, t.ID) - } else { - if ev.IsTrace() { - ev.Trace("- topic %s: %d subscribers, accessed %s", t.ID, subs, util.FormatTime(lastAccess)) - } - subscribers += subs + continue } + subscribers += subs } }). Debug("Removed %d empty topic(s)", emptyTopics) @@ -63,24 +58,10 @@ func (s *Server) execManager() { sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts() } - // Users - var usersCount int64 - if s.userManager != nil { - usersCount, err = s.userManager.UsersCount() - if err != nil { - log.Tag(tagManager).Err(err).Warn("Error counting users") - } - } - // Print stats - s.mu.RLock() + s.mu.Lock() messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors) - s.mu.RUnlock() - - // Update stats - s.updateAndWriteStats(messagesCount) - - // Log stats + s.mu.Unlock() log. Tag(tagManager). Fields(log.Context{ @@ -89,7 +70,6 @@ func (s *Server) execManager() { "topics_active": topicsCount, "subscribers": subscribers, "visitors": visitorsCount, - "users": usersCount, "emails_received": receivedMailTotal, "emails_received_success": receivedMailSuccess, "emails_received_failure": receivedMailFailure, @@ -98,11 +78,6 @@ func (s *Server) execManager() { "emails_sent_failure": sentMailFailure, }). Info("Server stats") - mset(metricMessagesCached, messagesCached) - mset(metricVisitors, visitorsCount) - mset(metricUsers, usersCount) - mset(metricSubscribers, subscribers) - mset(metricTopics, topicsCount) } func (s *Server) pruneVisitors() { @@ -141,30 +116,29 @@ func (s *Server) pruneTokens() { } func (s *Server) pruneAttachments() { - if s.fileCache == nil { - return + if s.fileCache != nil { + log. + Tag(tagManager). + Timing(func() { + ids, err := s.messageCache.AttachmentsExpired() + if err != nil { + log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments") + } else if len(ids) > 0 { + if log.Tag(tagManager).IsDebug() { + log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", ")) + } + if err := s.fileCache.Remove(ids...); err != nil { + log.Tag(tagManager).Err(err).Warn("Error deleting attachments") + } + if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil { + log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted") + } + } else { + log.Tag(tagManager).Debug("No expired attachments to delete") + } + }). + Debug("Deleted expired attachments") } - log. - Tag(tagManager). - Timing(func() { - ids, err := s.messageCache.AttachmentsExpired() - if err != nil { - log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments") - } else if len(ids) > 0 { - if log.Tag(tagManager).IsDebug() { - log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", ")) - } - if err := s.fileCache.Remove(ids...); err != nil { - log.Tag(tagManager).Err(err).Warn("Error deleting attachments") - } - if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil { - log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted") - } - } else { - log.Tag(tagManager).Debug("No expired attachments to delete") - } - }). - Debug("Deleted expired attachments") } func (s *Server) pruneMessages() { @@ -175,10 +149,8 @@ func (s *Server) pruneMessages() { if err != nil { log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages") } else if len(expiredMessageIDs) > 0 { - if s.fileCache != nil { - if err := s.fileCache.Remove(expiredMessageIDs...); err != nil { - log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages") - } + if err := s.fileCache.Remove(expiredMessageIDs...); err != nil { + log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages") } if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil { log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted") diff --git a/server/server_manager_test.go b/server/server_manager_test.go deleted file mode 100644 index f17d583..0000000 --- a/server/server_manager_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package server - -import ( - "github.com/stretchr/testify/require" - "testing" -) - -func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) { - // Tests that the manager runs without attachment-cache-dir set, see #617 - c := newTestConfig(t) - c.AttachmentCacheDir = "" - s := newTestServer(t, c) - - // Publish a message - rr := request(t, s, "POST", "/mytopic", "hi", nil) - require.Equal(t, 200, rr.Code) - m := toMessage(t, rr.Body.String()) - - // Expire message - require.Nil(t, s.messageCache.ExpireMessages("mytopic")) - - // Does not panic - s.pruneMessages() - - // Actually deleted - _, err := s.messageCache.Message(m.ID) - require.Equal(t, errMessageNotFound, err) -} diff --git a/server/server_matrix.go b/server/server_matrix.go index c25a1b5..28ca733 100644 --- a/server/server_matrix.go +++ b/server/server_matrix.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "strings" - "time" ) // Matrix Push Gateway / UnifiedPush / ntfy integration: @@ -72,27 +71,25 @@ type matrixResponse struct { Rejected []string `json:"rejected"` } +// errMatrix represents an error when handing Matrix gateway messages +type errMatrix struct { + pushKey string + err error +} + +func (e errMatrix) Error() string { + if e.err != nil { + return fmt.Sprintf("message with push key %s rejected: %s", e.pushKey, e.err.Error()) + } + return fmt.Sprintf("message with push key %s rejected", e.pushKey) +} + const ( - // matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter is the time after which a Matrix response - // will return an HTTP 200 with the push key (i.e. "rejected":[""]}), if no rate visitor has been set on - // the topic. Rejecting the push key will instruct the Matrix server to invalidate the pushkey and stop sending - // messages to it. This must be longer than topicExpungeAfter. See https://spec.matrix.org/v1.6/push-gateway-api/ - matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter = 12 * time.Hour + // matrixPushKeyHeader is a header that's used internally to pass the Matrix push key (from the matrixRequest) + // along with the request. The push key is only used if an error occurs down the line. + matrixPushKeyHeader = "X-Matrix-Pushkey" ) -// errMatrixPushkeyRejected represents an error when handing Matrix gateway messages -// -// If the push key is set, the app server will remove it and will never send messages using the same -// push key again, until the user repairs it. -type errMatrixPushkeyRejected struct { - rejectedPushKey string - configuredBaseURL string -} - -func (e errMatrixPushkeyRejected) Error() string { - return fmt.Sprintf("push key must be prefixed with base URL, received push key: %s, configured base URL: %s", e.rejectedPushKey, e.configuredBaseURL) -} - // newRequestFromMatrixJSON reads the request body as a Matrix JSON message, parses the "pushkey", and creates a new // HTTP request that looks like a normal ntfy request from it. // @@ -125,19 +122,17 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int) } pushKey := m.Notification.Devices[0].PushKey // We ignore other devices for now, see discussion in #316 if !strings.HasPrefix(pushKey, baseURL+"/") { - return nil, &errMatrixPushkeyRejected{rejectedPushKey: pushKey, configuredBaseURL: baseURL} + return nil, &errMatrix{pushKey: pushKey, err: wrapErrHTTP(errHTTPBadRequestMatrixPushkeyBaseURLMismatch, "received push key: %s, configured base URL: %s", pushKey, baseURL)} } newRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes))) if err != nil { - return nil, err + return nil, &errMatrix{pushKey: pushKey, err: err} } newRequest.RemoteAddr = r.RemoteAddr // Not strictly necessary, since visitor was already extracted if r.Header.Get("X-Forwarded-For") != "" { newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) } - newRequest = withContext(newRequest, map[contextKey]any{ - contextMatrixPushKey: pushKey, - }) + newRequest.Header.Set(matrixPushKeyHeader, pushKey) return newRequest, nil } @@ -149,6 +144,12 @@ func writeMatrixDiscoveryResponse(w http.ResponseWriter) error { return err } +// writeMatrixError logs and writes the errMatrix to the given http.ResponseWriter as a matrixResponse +func writeMatrixError(w http.ResponseWriter, r *http.Request, v *visitor, err *errMatrix) error { + logvr(v, r).Tag(tagMatrix).Err(err).Debug("Matrix gateway error") + return writeMatrixResponse(w, err.pushKey) +} + // writeMatrixSuccess writes a successful matrixResponse (no rejected push key) to the given http.ResponseWriter func writeMatrixSuccess(w http.ResponseWriter) error { return writeMatrixResponse(w, "") diff --git a/server/server_matrix_test.go b/server/server_matrix_test.go index e723ac0..883a8c1 100644 --- a/server/server_matrix_test.go +++ b/server/server_matrix_test.go @@ -3,6 +3,7 @@ package server import ( "net/http" "net/http/httptest" + "net/netip" "strings" "testing" @@ -18,6 +19,7 @@ func TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) { require.Nil(t, err) require.Equal(t, "POST", newRequest.Method) require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.URL.String()) + require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.Header.Get("X-Matrix-Pushkey")) require.Equal(t, body, readAll(t, newRequest.Body)) } @@ -54,10 +56,10 @@ func TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) { body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.example.com/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}` r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body)) _, err := newRequestFromMatrixJSON(r, baseURL, maxLength) - matrixErr, ok := err.(*errMatrixPushkeyRejected) + matrixErr, ok := err.(*errMatrix) require.True(t, ok) - require.Equal(t, "push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.Error()) - require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.rejectedPushKey) + require.Equal(t, "invalid request: push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.err.Error()) + require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.pushKey) } func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) { @@ -69,7 +71,9 @@ func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) { func TestMatrix_WriteMatrixError(t *testing.T) { w := httptest.NewRecorder() - require.Nil(t, writeMatrixResponse(w, "https://ntfy.example.com/upABCDEFGHI?up=1")) + r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil) + v := newVisitor(newTestConfig(t), nil, nil, netip.MustParseAddr("1.2.3.4"), nil) + require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch})) require.Equal(t, 200, w.Result().StatusCode) require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String()) } diff --git a/server/server_metrics.go b/server/server_metrics.go deleted file mode 100644 index 88fa9f1..0000000 --- a/server/server_metrics.go +++ /dev/null @@ -1,132 +0,0 @@ -package server - -import ( - "github.com/prometheus/client_golang/prometheus" -) - -var ( - metricMessagesPublishedSuccess prometheus.Counter - metricMessagesPublishedFailure prometheus.Counter - metricMessagesCached prometheus.Gauge - metricMessagePublishDurationMillis prometheus.Gauge - metricFirebasePublishedSuccess prometheus.Counter - metricFirebasePublishedFailure prometheus.Counter - metricEmailsPublishedSuccess prometheus.Counter - metricEmailsPublishedFailure prometheus.Counter - metricEmailsReceivedSuccess prometheus.Counter - metricEmailsReceivedFailure prometheus.Counter - metricCallsMadeSuccess prometheus.Counter - metricCallsMadeFailure prometheus.Counter - metricUnifiedPushPublishedSuccess prometheus.Counter - metricMatrixPublishedSuccess prometheus.Counter - metricMatrixPublishedFailure prometheus.Counter - metricAttachmentsTotalSize prometheus.Gauge - metricVisitors prometheus.Gauge - metricSubscribers prometheus.Gauge - metricTopics prometheus.Gauge - metricUsers prometheus.Gauge - metricHTTPRequests *prometheus.CounterVec -) - -func initMetrics() { - metricMessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_messages_published_success", - }) - metricMessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_messages_published_failure", - }) - metricMessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "ntfy_messages_cached_total", - }) - metricMessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "ntfy_message_publish_duration_ms", - }) - metricFirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_firebase_published_success", - }) - metricFirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_firebase_published_failure", - }) - metricEmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_emails_sent_success", - }) - metricEmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_emails_sent_failure", - }) - metricEmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_emails_received_success", - }) - metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_emails_received_failure", - }) - metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_calls_made_success", - }) - metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_calls_made_failure", - }) - metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_unifiedpush_published_success", - }) - metricMatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_matrix_published_success", - }) - metricMatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_matrix_published_failure", - }) - metricAttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "ntfy_attachments_total_size", - }) - metricVisitors = prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "ntfy_visitors_total", - }) - metricUsers = prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "ntfy_users_total", - }) - metricSubscribers = prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "ntfy_subscribers_total", - }) - metricTopics = prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "ntfy_topics_total", - }) - metricHTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "ntfy_http_requests_total", - }, []string{"http_code", "ntfy_code", "http_method"}) - prometheus.MustRegister( - metricMessagesPublishedSuccess, - metricMessagesPublishedFailure, - metricMessagesCached, - metricMessagePublishDurationMillis, - metricFirebasePublishedSuccess, - metricFirebasePublishedFailure, - metricEmailsPublishedSuccess, - metricEmailsPublishedFailure, - metricEmailsReceivedSuccess, - metricEmailsReceivedFailure, - metricCallsMadeSuccess, - metricCallsMadeFailure, - metricUnifiedPushPublishedSuccess, - metricMatrixPublishedSuccess, - metricMatrixPublishedFailure, - metricAttachmentsTotalSize, - metricVisitors, - metricUsers, - metricSubscribers, - metricTopics, - metricHTTPRequests, - ) -} - -// minc increments a prometheus.Counter if it is non-nil -func minc(counter prometheus.Counter) { - if counter != nil { - counter.Inc() - } -} - -// mset sets a prometheus.Gauge if it is non-nil -func mset[T int | int64 | float64](gauge prometheus.Gauge, value T) { - if gauge != nil { - gauge.Set(float64(value)) - } -} diff --git a/server/server_middleware.go b/server/server_middleware.go index 7aea45a..684253a 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -1,17 +1,8 @@ package server import ( - "net/http" - "heckel.io/ntfy/util" -) - -type contextKey int - -const ( - contextRateVisitor contextKey = iota + 2586 - contextTopic - contextMatrixPushKey + "net/http" ) func (s *Server) limitRequests(next handleFunc) handleFunc { @@ -25,33 +16,9 @@ func (s *Server) limitRequests(next handleFunc) handleFunc { } } -// limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context -func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc { - return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - t, err := s.topicFromPath(r.URL.Path) - if err != nil { - return err - } - vrate := v - if rateVisitor := t.RateVisitor(); rateVisitor != nil { - vrate = rateVisitor - } - r = withContext(r, map[contextKey]any{ - contextRateVisitor: vrate, - contextTopic: t, - }) - if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) { - return next(w, r, v) - } else if !vrate.RequestAllowed() { - return errHTTPTooManyRequestsLimitRequests - } - return next(w, r, v) - } -} - func (s *Server) ensureWebEnabled(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.config.WebRoot == "" { + if !s.config.EnableWeb { return errHTTPNotFound } return next(w, r, v) @@ -76,24 +43,6 @@ func (s *Server) ensureUser(next handleFunc) handleFunc { }) } -func (s *Server) ensureAdmin(next handleFunc) handleFunc { - return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if !v.User().IsAdmin() { - return errHTTPUnauthorized - } - return next(w, r, v) - }) -} - -func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc { - return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.config.TwilioAccount == "" || s.userManager == nil { - return errHTTPNotFound - } - return next(w, r, v) - } -} - func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.StripeSecretKey == "" || s.stripe == nil { diff --git a/server/server_payments.go b/server/server_payments.go index 1e98d05..d812837 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -68,7 +68,6 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ Messages: freeTier.MessageLimit, MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()), Emails: freeTier.EmailLimit, - Calls: freeTier.CallLimit, Reservations: freeTier.ReservationsLimit, AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, AttachmentFileSize: freeTier.AttachmentFileSizeLimit, @@ -81,23 +80,19 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ return err } for _, tier := range tiers { - priceMonth, priceYear := prices[tier.StripeMonthlyPriceID], prices[tier.StripeYearlyPriceID] - if priceMonth == 0 || priceYear == 0 { // Only allow tiers that have both prices! + priceStr, ok := prices[tier.StripePriceID] + if tier.StripePriceID == "" || !ok { continue } response = append(response, &apiAccountBillingTier{ - Code: tier.Code, - Name: tier.Name, - Prices: &apiAccountBillingPrices{ - Month: priceMonth, - Year: priceYear, - }, + Code: tier.Code, + Name: tier.Name, + Price: priceStr, Limits: &apiAccountLimits{ Basis: string(visitorLimitBasisTier), Messages: tier.MessageLimit, MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()), Emails: tier.EmailLimit, - Calls: tier.CallLimit, Reservations: tier.ReservationLimit, AttachmentTotalSize: tier.AttachmentTotalSizeLimit, AttachmentFileSize: tier.AttachmentFileSizeLimit, @@ -122,21 +117,11 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r tier, err := s.userManager.Tier(req.Tier) if err != nil { return err - } - var priceID string - if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" { - priceID = tier.StripeMonthlyPriceID - } else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" { - priceID = tier.StripeYearlyPriceID - } else { + } else if tier.StripePriceID == "" { return errNotAPaidTier } logvr(v, r). With(tier). - Fields(log.Context{ - "stripe_price_id": priceID, - "stripe_subscription_interval": req.Interval, - }). Tag(tagStripe). Info("Creating Stripe checkout flow") var stripeCustomerID *string @@ -158,7 +143,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r AllowPromotionCodes: stripe.Bool(true), LineItems: []*stripe.CheckoutSessionLineItemParams{ { - Price: stripe.String(priceID), + Price: stripe.String(tier.StripePriceID), Quantity: stripe.Int64(1), }, }, @@ -190,16 +175,15 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr if err != nil { return err } else if sess.Customer == nil || sess.Subscription == nil || sess.ClientReferenceID == "" { - return errHTTPBadRequestBillingRequestInvalid.Wrap("customer or subscription not found") + return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "customer or subscription not found") } sub, err := s.stripe.GetSubscription(sess.Subscription.ID) if err != nil { return err - } else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil || sub.Items.Data[0].Price.Recurring == nil { - return errHTTPBadRequestBillingRequestInvalid.Wrap("more than one line item in existing subscription") + } else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil { + return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "more than one line item in existing subscription") } - priceID, interval := sub.Items.Data[0].Price.ID, sub.Items.Data[0].Price.Recurring.Interval - tier, err := s.userManager.TierByStripePrice(priceID) + tier, err := s.userManager.TierByStripePrice(sub.Items.Data[0].Price.ID) if err != nil { return err } @@ -213,10 +197,8 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr Tag(tagStripe). Fields(log.Context{ "stripe_customer_id": sess.Customer.ID, - "stripe_price_id": priceID, "stripe_subscription_id": sub.ID, "stripe_subscription_status": string(sub.Status), - "stripe_subscription_interval": string(interval), "stripe_subscription_paid_until": sub.CurrentPeriodEnd, }). Info("Stripe checkout flow succeeded, updating user tier and subscription") @@ -231,7 +213,7 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr if _, err := s.stripe.UpdateCustomer(sess.Customer.ID, customerParams); err != nil { return err } - if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil { + if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), sub.CurrentPeriodEnd, sub.CancelAt); err != nil { return err } http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther) @@ -253,37 +235,28 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r if err != nil { return err } - var priceID string - if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" { - priceID = tier.StripeMonthlyPriceID - } else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" { - priceID = tier.StripeYearlyPriceID - } else { - return errNotAPaidTier - } logvr(v, r). Tag(tagStripe). Fields(log.Context{ - "new_tier_id": tier.ID, - "new_tier_code": tier.Code, - "new_tier_stripe_price_id": priceID, - "new_tier_stripe_subscription_interval": req.Interval, + "new_tier_id": tier.ID, + "new_tier_name": tier.Name, + "new_tier_stripe_price_id": tier.StripePriceID, // Other stripe_* fields filled by visitor context }). - Info("Changing Stripe subscription and billing tier to %s/%s (price %s, %s)", tier.ID, tier.Name, priceID, req.Interval) + Info("Changing Stripe subscription and billing tier to %s/%s (price %s)", tier.ID, tier.Name, tier.StripePriceID) sub, err := s.stripe.GetSubscription(u.Billing.StripeSubscriptionID) if err != nil { return err } else if sub.Items == nil || len(sub.Items.Data) != 1 { - return errHTTPBadRequestBillingRequestInvalid.Wrap("no items, or more than one item") + return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "no items, or more than one item") } params := &stripe.SubscriptionParams{ CancelAtPeriodEnd: stripe.Bool(false), - ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)), + ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)), Items: []*stripe.SubscriptionItemsParams{ { ID: stripe.String(sub.Items.Data[0].ID), - Price: stripe.String(priceID), + Price: stripe.String(tier.StripePriceID), }, }, } @@ -372,22 +345,20 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request, ev, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw))) if err != nil { return err - } else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" || ev.Items.Data[0].Price.Recurring == nil { - logvr(v, r).Tag(tagStripe).Field("stripe_request", fmt.Sprintf("%#v", ev)).Warn("Unexpected request from Stripe") + } else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" { return errHTTPBadRequestBillingRequestInvalid } - subscriptionID, priceID, interval := ev.ID, ev.Items.Data[0].Price.ID, ev.Items.Data[0].Price.Recurring.Interval + subscriptionID, priceID := ev.ID, ev.Items.Data[0].Price.ID logvr(v, r). Tag(tagStripe). Fields(log.Context{ "stripe_webhook_type": event.Type, "stripe_customer_id": ev.Customer, - "stripe_price_id": priceID, "stripe_subscription_id": ev.ID, "stripe_subscription_status": ev.Status, - "stripe_subscription_interval": interval, "stripe_subscription_paid_until": ev.CurrentPeriodEnd, "stripe_subscription_cancel_at": ev.CancelAt, + "stripe_price_id": priceID, }). Info("Updating subscription to status %s, with price %s", ev.Status, priceID) userFn := func() (*user.User, error) { @@ -405,7 +376,7 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request, if err != nil { return err } - if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, string(interval), ev.CurrentPeriodEnd, ev.CancelAt); err != nil { + if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, ev.CurrentPeriodEnd, ev.CancelAt); err != nil { return err } s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u)) @@ -428,14 +399,14 @@ func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(r *http.Request, Tag(tagStripe). Field("stripe_webhook_type", event.Type). Info("Subscription deleted, downgrading to unpaid tier") - if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", "", 0, 0); err != nil { + if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", 0, 0); err != nil { return err } s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u)) return nil } -func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status, interval string, paidUntil, cancelAt int64) error { +func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error { reservationsLimit := visitorDefaultReservationsLimit if tier != nil { reservationsLimit = tier.ReservationLimit @@ -452,8 +423,9 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user. logvr(v, r). Tag(tagStripe). Fields(log.Context{ - "new_tier_id": tier.ID, - "new_tier_code": tier.Code, + "new_tier_id": tier.ID, + "new_tier_name": tier.Name, + "new_tier_stripe_price_id": tier.StripePriceID, }). Info("Changing tier to tier %s (%s) for user %s", tier.ID, tier.Name, u.Name) if err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil { @@ -465,7 +437,6 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user. StripeCustomerID: customerID, StripeSubscriptionID: subscriptionID, StripeSubscriptionStatus: stripe.SubscriptionStatus(status), - StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval), StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0), StripeSubscriptionCancelAt: time.Unix(cancelAt, 0), } @@ -477,16 +448,20 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user. // fetchStripePrices contacts the Stripe API to retrieve all prices. This is used by the server to cache the prices // in memory, and ultimately for the web app to display the price table. -func (s *Server) fetchStripePrices() (map[string]int64, error) { +func (s *Server) fetchStripePrices() (map[string]string, error) { log.Debug("Caching prices from Stripe API") - priceMap := make(map[string]int64) + priceMap := make(map[string]string) prices, err := s.stripe.ListPrices(&stripe.PriceListParams{Active: stripe.Bool(true)}) if err != nil { log.Warn("Fetching Stripe prices failed: %s", err.Error()) return nil, err } for _, p := range prices { - priceMap[p.ID] = p.UnitAmount + if p.UnitAmount%100 == 0 { + priceMap[p.ID] = fmt.Sprintf("$%d", p.UnitAmount/100) + } else { + priceMap[p.ID] = fmt.Sprintf("$%.2f", float64(p.UnitAmount)/100) + } log.Trace("- Caching price %s = %v", p.ID, priceMap[p.ID]) } return priceMap, nil diff --git a/server/server_payments_test.go b/server/server_payments_test.go index ebd559e..4640a72 100644 --- a/server/server_payments_test.go +++ b/server/server_payments_test.go @@ -37,9 +37,7 @@ func TestPayments_Tiers(t *testing.T) { On("ListPrices", mock.Anything). Return([]*stripe.Price{ {ID: "price_123", UnitAmount: 500}, - {ID: "price_124", UnitAmount: 5000}, {ID: "price_456", UnitAmount: 1000}, - {ID: "price_457", UnitAmount: 10000}, {ID: "price_999", UnitAmount: 9999}, }, nil) @@ -60,8 +58,7 @@ func TestPayments_Tiers(t *testing.T) { AttachmentFileSizeLimit: 999, AttachmentTotalSizeLimit: 888, AttachmentExpiryDuration: time.Minute, - StripeMonthlyPriceID: "price_123", - StripeYearlyPriceID: "price_124", + StripePriceID: "price_123", })) require.Nil(t, s.userManager.AddTier(&user.Tier{ ID: "ti_444", @@ -74,8 +71,7 @@ func TestPayments_Tiers(t *testing.T) { AttachmentFileSizeLimit: 999111, AttachmentTotalSizeLimit: 888111, AttachmentExpiryDuration: time.Hour, - StripeMonthlyPriceID: "price_456", - StripeYearlyPriceID: "price_457", + StripePriceID: "price_456", })) response := request(t, s, "GET", "/v1/tiers", "", nil) require.Equal(t, 200, response.Code) @@ -102,8 +98,6 @@ func TestPayments_Tiers(t *testing.T) { require.Equal(t, "pro", tier.Code) require.Equal(t, "Pro", tier.Name) require.Equal(t, "tier", tier.Limits.Basis) - require.Equal(t, int64(500), tier.Prices.Month) - require.Equal(t, int64(5000), tier.Prices.Year) require.Equal(t, int64(777), tier.Limits.Reservations) require.Equal(t, int64(1000), tier.Limits.Messages) require.Equal(t, int64(3600), tier.Limits.MessagesExpiryDuration) @@ -115,8 +109,6 @@ func TestPayments_Tiers(t *testing.T) { tier = tiers[2] require.Equal(t, "business", tier.Code) require.Equal(t, "Business", tier.Name) - require.Equal(t, int64(1000), tier.Prices.Month) - require.Equal(t, int64(10000), tier.Prices.Year) require.Equal(t, "tier", tier.Limits.Basis) require.Equal(t, int64(777333), tier.Limits.Reservations) require.Equal(t, int64(2000), tier.Limits.Messages) @@ -144,14 +136,14 @@ func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) { // Create tier and user require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_123", - Code: "pro", - StripeMonthlyPriceID: "price_123", + ID: "ti_123", + Code: "pro", + StripePriceID: "price_123", })) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) // Create subscription - response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{ + response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{ "Authorization": util.BasicAuth("phil", "phil"), }) require.Equal(t, 200, response.Code) @@ -180,9 +172,9 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) { // Create tier and user require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_123", - Code: "pro", - StripeMonthlyPriceID: "price_123", + ID: "ti_123", + Code: "pro", + StripePriceID: "price_123", })) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) @@ -195,7 +187,7 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) { require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) // Create subscription - response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{ + response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{ "Authorization": util.BasicAuth("phil", "phil"), }) require.Equal(t, 200, response.Code) @@ -222,9 +214,9 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) { // Create tier and user require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_123", - Code: "pro", - StripeMonthlyPriceID: "price_123", + ID: "ti_123", + Code: "pro", + StripePriceID: "price_123", })) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) @@ -275,7 +267,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes require.Nil(t, s.userManager.AddTier(&user.Tier{ ID: "ti_123", Code: "starter", - StripeMonthlyPriceID: "price_1234", + StripePriceID: "price_1234", ReservationLimit: 1, MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in MessageExpiryDuration: time.Hour, @@ -306,12 +298,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes Items: &stripe.SubscriptionItemList{ Data: []*stripe.SubscriptionItem{ { - Price: &stripe.Price{ - ID: "price_1234", - Recurring: &stripe.PriceRecurring{ - Interval: stripe.PriceRecurringIntervalMonth, - }, - }, + Price: &stripe.Price{ID: "price_1234"}, }, }, }, @@ -346,7 +333,6 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes require.Equal(t, "", u.Billing.StripeCustomerID) require.Equal(t, "", u.Billing.StripeSubscriptionID) require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus) - require.Equal(t, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval) require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix()) require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users! @@ -363,7 +349,6 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID) require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) - require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval) require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix()) require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix()) require.Equal(t, int64(0), u.Stats.Messages) @@ -415,8 +400,6 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes } func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) { - t.Parallel() - // This tests incoming webhooks from Stripe to update a subscription: // - All Stripe columns are updated in the user table // - When downgrading, excess reservations are deleted, including messages and attachments in @@ -440,7 +423,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( require.Nil(t, s.userManager.AddTier(&user.Tier{ ID: "ti_1", Code: "starter", - StripeMonthlyPriceID: "price_1234", // ! + StripePriceID: "price_1234", // ! ReservationLimit: 1, // ! MessageLimit: 100, MessageExpiryDuration: time.Hour, @@ -452,7 +435,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( require.Nil(t, s.userManager.AddTier(&user.Tier{ ID: "ti_2", Code: "pro", - StripeMonthlyPriceID: "price_1111", // ! + StripePriceID: "price_1111", // ! ReservationLimit: 3, // ! MessageLimit: 200, MessageExpiryDuration: time.Hour, @@ -474,7 +457,6 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( StripeCustomerID: "acct_5555", StripeSubscriptionID: "sub_1234", StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue, - StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth, StripeSubscriptionPaidUntil: time.Unix(123, 0), StripeSubscriptionCancelAt: time.Unix(456, 0), } @@ -517,10 +499,9 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( require.Equal(t, "starter", u.Tier.Code) // Not "pro" require.Equal(t, "acct_5555", u.Billing.StripeCustomerID) require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID) - require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due" - require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month" - require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated - require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated + require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due" + require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated + require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated // Verify that reservations were deleted r, err := s.userManager.Reservations("phil") @@ -565,10 +546,10 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) { // Create a user with a Stripe subscription and 3 reservations require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_1", - Code: "pro", - StripeMonthlyPriceID: "price_1234", - ReservationLimit: 1, + ID: "ti_1", + Code: "pro", + StripePriceID: "price_1234", + ReservationLimit: 1, })) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) require.Nil(t, s.userManager.ChangeTier("phil", "pro")) @@ -581,7 +562,6 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) { StripeCustomerID: "acct_5555", StripeSubscriptionID: "sub_1234", StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue, - StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth, StripeSubscriptionPaidUntil: time.Unix(123, 0), StripeSubscriptionCancelAt: time.Unix(0, 0), })) @@ -635,11 +615,11 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) { stripeMock. On("UpdateSubscription", "sub_123", &stripe.SubscriptionParams{ CancelAtPeriodEnd: stripe.Bool(false), - ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)), + ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)), Items: []*stripe.SubscriptionItemsParams{ { ID: stripe.String("someid_123"), - Price: stripe.String("price_457"), + Price: stripe.String("price_456"), }, }, }). @@ -647,16 +627,14 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) { // Create tier and user require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_123", - Code: "pro", - StripeMonthlyPriceID: "price_123", - StripeYearlyPriceID: "price_124", + ID: "ti_123", + Code: "pro", + StripePriceID: "price_123", })) require.Nil(t, s.userManager.AddTier(&user.Tier{ - ID: "ti_456", - Code: "business", - StripeMonthlyPriceID: "price_456", - StripeYearlyPriceID: "price_457", + ID: "ti_456", + Code: "business", + StripePriceID: "price_456", })) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) require.Nil(t, s.userManager.ChangeTier("phil", "pro")) @@ -666,7 +644,7 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) { })) // Call endpoint to change subscription - rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business","interval":"year"}`, map[string]string{ + rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business"}`, map[string]string{ "Authorization": util.BasicAuth("phil", "phil"), }) require.Equal(t, 200, rr.Code) @@ -817,10 +795,7 @@ const subscriptionUpdatedEventJSON = ` "data": [ { "price": { - "id": "price_1234", - "recurring": { - "interval": "year" - } + "id": "price_1234" } } ] @@ -843,10 +818,7 @@ const subscriptionDeletedEventJSON = ` "data": [ { "price": { - "id": "price_1234", - "recurring": { - "interval": "month" - } + "id": "price_1234" } } ] diff --git a/server/server_test.go b/server/server_test.go index d7c4a7c..f183f08 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -15,13 +15,13 @@ import ( "net/netip" "os" "path/filepath" - "runtime/debug" "strings" "sync" - "sync/atomic" "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "heckel.io/ntfy/log" "heckel.io/ntfy/util" @@ -83,34 +83,7 @@ func TestServer_PublishWithFirebase(t *testing.T) { require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"]) } -func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) { - // This tests issue #641, which used to panic before the fix - - firebaseKeyFile := filepath.Join(t.TempDir(), "firebase.json") - contents := `{ - "type": "service_account", - "project_id": "ntfy-test", - "private_key_id": "fsfhskjdfhskdhfskdjfhsdf", - "private_key": "lalala", - "client_email": "firebase-adminsdk-muv04@ntfy-test.iam.gserviceaccount.com", - "client_id": "123123213", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-muv04%40ntfy-test.iam.gserviceaccount.com" -} -` - require.Nil(t, os.WriteFile(firebaseKeyFile, []byte(contents), 0600)) - c := newTestConfig(t) - c.FirebaseKeyFile = firebaseKeyFile - s := newTestServer(t, c) - - response := request(t, s, "PUT", "/mytopic", "my first message", nil) - require.Equal(t, "my first message", toMessage(t, response.Body.String()).Message) -} - func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { - t.Parallel() c := newTestConfig(t) c.KeepaliveInterval = time.Second s := newTestServer(t, c) @@ -149,7 +122,6 @@ func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { } func TestServer_PublishAndSubscribe(t *testing.T) { - t.Parallel() s := newTestServer(t, newTestConfig(t)) subscribeRR := httptest.NewRecorder() @@ -177,8 +149,6 @@ func TestServer_PublishAndSubscribe(t *testing.T) { require.Equal(t, "", messages[1].Title) require.Equal(t, 0, messages[1].Priority) require.Nil(t, messages[1].Tags) - require.True(t, time.Now().Add(12*time.Hour-5*time.Second).Unix() < messages[1].Expires) - require.True(t, time.Now().Add(12*time.Hour+5*time.Second).Unix() > messages[1].Expires) require.Equal(t, messageEvent, messages[2].Event) require.Equal(t, "mytopic", messages[2].Topic) @@ -219,7 +189,11 @@ func TestServer_StaticSites(t *testing.T) { rr = request(t, s, "GET", "/mytopic", "", nil) require.Equal(t, 200, rr.Code) - require.Contains(t, rr.Body.String(), ``) + require.Contains(t, rr.Body.String(), ``) + + rr = request(t, s, "GET", "/static/css/home.css", "", nil) + require.Equal(t, 200, rr.Code) + require.Contains(t, rr.Body.String(), `/* general styling */`) rr = request(t, s, "GET", "/docs", "", nil) require.Equal(t, 301, rr.Code) @@ -229,7 +203,7 @@ func TestServer_StaticSites(t *testing.T) { func TestServer_WebEnabled(t *testing.T) { conf := newTestConfig(t) - conf.WebRoot = "" // Disable web app + conf.EnableWeb = false s := newTestServer(t, conf) rr := request(t, s, "GET", "/", "", nil) @@ -242,7 +216,7 @@ func TestServer_WebEnabled(t *testing.T) { require.Equal(t, 404, rr.Code) conf2 := newTestConfig(t) - conf2.WebRoot = "/" + conf2.EnableWeb = true s2 := newTestServer(t, conf2) rr = request(t, s2, "GET", "/", "", nil) @@ -250,6 +224,9 @@ func TestServer_WebEnabled(t *testing.T) { rr = request(t, s2, "GET", "/config.js", "", nil) require.Equal(t, 200, rr.Code) + + rr = request(t, s2, "GET", "/static/css/home.css", "", nil) + require.Equal(t, 200, rr.Code) } func TestServer_PublishLargeMessage(t *testing.T) { @@ -310,7 +287,6 @@ func TestServer_PublishNoCache(t *testing.T) { msg := toMessage(t, response.Body.String()) require.NotEmpty(t, msg.ID) require.Equal(t, "this message is not cached", msg.Message) - require.Equal(t, int64(0), msg.Expires) response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) messages := toMessages(t, response.Body.String()) @@ -318,11 +294,13 @@ func TestServer_PublishNoCache(t *testing.T) { } func TestServer_PublishAt(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) + c := newTestConfig(t) + c.MinDelay = time.Second + c.DelayedSenderInterval = 100 * time.Millisecond + s := newTestServer(t, c) response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ - "In": "1h", + "In": "1s", }) require.Equal(t, 200, response.Code) @@ -330,62 +308,22 @@ func TestServer_PublishAt(t *testing.T) { messages := toMessages(t, response.Body.String()) require.Equal(t, 0, len(messages)) - // Update message time to the past - fakeTime := time.Now().Add(-10 * time.Second).Unix() - _, err := s.messageCache.db.Exec(`UPDATE messages SET time=?`, fakeTime) - require.Nil(t, err) - - // Trigger delayed message sending + time.Sleep(time.Second) require.Nil(t, s.sendDelayedMessages()) + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) messages = toMessages(t, response.Body.String()) require.Equal(t, 1, len(messages)) require.Equal(t, "a message", messages[0].Message) require.Equal(t, netip.Addr{}, messages[0].Sender) // Never return the sender! - messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true) + messages, err := s.messageCache.Messages("mytopic", sinceAllMessages, true) require.Nil(t, err) require.Equal(t, 1, len(messages)) require.Equal(t, "a message", messages[0].Message) require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though! } -func TestServer_PublishAt_FromUser(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfigWithAuthFile(t)) - - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) - response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - "In": "1h", - }) - require.Equal(t, 200, response.Code) - - // Message doesn't show up immediately - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 0, len(messages)) - - // Update message time to the past - fakeTime := time.Now().Add(-10 * time.Second).Unix() - _, err := s.messageCache.db.Exec(`UPDATE messages SET time=?`, fakeTime) - require.Nil(t, err) - - // Trigger delayed message sending - require.Nil(t, s.sendDelayedMessages()) - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages = toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, fakeTime, messages[0].Time) - require.Equal(t, "a message", messages[0].Message) - - messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true) - require.Nil(t, err) - require.Equal(t, 1, len(messages)) - require.Equal(t, "a message", messages[0].Message) - require.True(t, strings.HasPrefix(messages[0].User, "u_")) -} - func TestServer_PublishAt_Expires(t *testing.T) { s := newTestServer(t, newTestConfig(t)) @@ -511,7 +449,6 @@ func TestServer_PublishWithNopCache(t *testing.T) { } func TestServer_PublishAndPollSince(t *testing.T) { - t.Parallel() s := newTestServer(t, newTestConfig(t)) request(t, s, "PUT", "/mytopic", "test 1", nil) @@ -692,7 +629,6 @@ func TestServer_PollWithQueryFilters(t *testing.T) { } func TestServer_SubscribeWithQueryFilters(t *testing.T) { - t.Parallel() c := newTestConfig(t) c.KeepaliveInterval = 800 * time.Millisecond s := newTestServer(t, c) @@ -825,7 +761,6 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) { func TestServer_Auth_Fail_Rate_Limiting(t *testing.T) { c := newTestConfigWithAuthFile(t) - c.VisitorAuthFailureLimitBurst = 10 s := newTestServer(t, c) for i := 0; i < 10; i++ { @@ -858,27 +793,7 @@ func TestServer_Auth_ViaQuery(t *testing.T) { require.Equal(t, 401, response.Code) } -func TestServer_Auth_NonBasicHeader(t *testing.T) { - s := newTestServer(t, newTestConfigWithAuthFile(t)) - - response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ - "Authorization": "WebPush not-supported", - }) - require.Equal(t, 200, response.Code) - - response = request(t, s, "PUT", "/mytopic", "test", map[string]string{ - "Authorization": "Bearer supported", - }) - require.Equal(t, 401, response.Code) - - response = request(t, s, "PUT", "/mytopic", "test", map[string]string{ - "Authorization": "basic supported", - }) - require.Equal(t, 401, response.Code) -} - func TestServer_StatsResetter(t *testing.T) { - t.Parallel() // This tests the stats resetter for // - an anonymous user // - a user without a tier (treated like the same as the anonymous user) @@ -945,15 +860,7 @@ func TestServer_StatsResetter(t *testing.T) { require.Equal(t, int64(2), account.Stats.Messages) // Wait for stats resetter to run - waitFor(t, func() bool { - response = request(t, s, "GET", "/v1/account", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) - require.Nil(t, err) - return account.Stats.Messages == 0 - }) + time.Sleep(2200 * time.Millisecond) // User stats show 0 messages now! response = request(t, s, "GET", "/v1/account", "", map[string]string{ @@ -1027,8 +934,6 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) { } func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) { - t.Parallel() - // This tests that the daily message quota is prefilled originally from the database, // if the visitor is unknown @@ -1101,29 +1006,15 @@ func TestServer_PublishTooRequests_Defaults(t *testing.T) { func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) { c := newTestConfig(t) - c.VisitorRequestLimitBurst = 3 c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() s := newTestServer(t, c) - for i := 0; i < 5; i++ { // > 3 + for i := 0; i < 65; i++ { // > 60 response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) require.Equal(t, 200, response.Code) } } -func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) { - c := newTestConfig(t) - c.VisitorRequestLimitBurst = 10 - c.VisitorMessageDailyLimit = 4 - c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() - s := newTestServer(t, c) - for i := 0; i < 8; i++ { // 4 - response := request(t, s, "PUT", "/mytopic", "message", nil) - require.Equal(t, 200, response.Code) - } -} - func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) { - t.Parallel() c := newTestConfig(t) c.VisitorRequestLimitBurst = 60 c.VisitorRequestLimitReplenish = time.Second @@ -1156,7 +1047,6 @@ func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { } func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { - t.Parallel() c := newTestConfig(t) c.VisitorEmailLimitReplenish = 500 * time.Millisecond s := newTestServer(t, c) @@ -1191,20 +1081,7 @@ func TestServer_PublishDelayedEmail_Fail(t *testing.T) { "E-Mail": "test@example.com", "Delay": "20 min", }) - require.Equal(t, 40003, toHTTPError(t, response.Body.String()).Code) -} - -func TestServer_PublishDelayedCall_Fail(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) - response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ - "Call": "yes", - "Delay": "20 min", - }) - require.Equal(t, 40037, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 400, response.Code) } func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { @@ -1215,63 +1092,6 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { require.Equal(t, 400, response.Code) } -func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - - subFn := func(v *visitor, msg *message) error { - return nil - } - - // Publish and check last access - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "Cache": "no", - }) - require.Equal(t, 200, response.Code) - waitFor(t, func() bool { - // .lastAccess set in t.Publish() -> t.Keepalive() in Goroutine - s.topics["mytopic"].mu.RLock() - defer s.topics["mytopic"].mu.RUnlock() - return s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2 && - s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2 - }) - - // Topic won't get pruned - s.execManager() - require.NotNil(t, s.topics["mytopic"]) - - // Fudge with last access, but subscribe, and see that it won't get pruned (because of subscriber) - subID := s.topics["mytopic"].Subscribe(subFn, "", func() {}) - s.topics["mytopic"].mu.Lock() - s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) - s.topics["mytopic"].mu.Unlock() - s.execManager() - require.NotNil(t, s.topics["mytopic"]) - - // It'll finally get pruned now that there are no subscribers and last access is 17 hours ago - s.topics["mytopic"].Unsubscribe(subID) - s.execManager() - require.Nil(t, s.topics["mytopic"]) -} - -func TestServer_TopicKeepaliveOnPoll(t *testing.T) { - t.Parallel() - s := newTestServer(t, newTestConfig(t)) - - // Create topic by polling once - response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) - - // Mess with last access time - s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) - - // Poll again and check keepalive time - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - require.Equal(t, 200, response.Code) - require.True(t, s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2) - require.True(t, s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2) -} - func TestServer_UnifiedPushDiscovery(t *testing.T) { s := newTestServer(t, newTestConfig(t)) response := request(t, s, "GET", "/mytopic?up=1", "", nil) @@ -1285,15 +1105,7 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) { require.Nil(t, err) s := newTestServer(t, newTestConfig(t)) - - // Register a UnifiedPush subscriber - response := request(t, s, "GET", "/up123456789012/json?poll=1", "", map[string]string{ - "Rate-Topics": "up123456789012", - }) - require.Equal(t, 200, response.Code) - - // Publish message to topic - response = request(t, s, "PUT", "/up123456789012?up=1", string(b), nil) + response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) @@ -1302,8 +1114,7 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) { require.Nil(t, err) require.Equal(t, b, b2) - // Retrieve and check published message - response = request(t, s, "GET", "/up123456789012/json?poll=1", string(b), nil) + response = request(t, s, "GET", "/mytopic/json?poll=1", string(b), nil) require.Equal(t, 200, response.Code) m = toMessage(t, response.Body.String()) require.Equal(t, "base64", m.Encoding) @@ -1318,15 +1129,7 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) { require.Nil(t, err) s := newTestServer(t, newTestConfig(t)) - - // Register a UnifiedPush subscriber - response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ - "Rate-Topics": "mytopic", - }) - require.Equal(t, 200, response.Code) - - // Publish message to topic - response = request(t, s, "PUT", "/mytopic?up=1", string(b), nil) + response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) @@ -1339,15 +1142,7 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) { func TestServer_PublishUnifiedPushText(t *testing.T) { s := newTestServer(t, newTestConfig(t)) - - // Register a UnifiedPush subscriber - response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ - "Rate-Topics": "mytopic", - }) - require.Equal(t, 200, response.Code) - - // Publish UnifiedPush text message - response = request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil) + response := request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) @@ -1374,14 +1169,8 @@ func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) { func TestServer_MatrixGateway_Push_Success(t *testing.T) { s := newTestServer(t, newTestConfig(t)) - - response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ - "Rate-Topics": "mytopic", // Register first! - }) - require.Equal(t, 200, response.Code) - notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` - response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) + response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) require.Equal(t, 200, response.Code) require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String()) @@ -1391,42 +1180,6 @@ func TestServer_MatrixGateway_Push_Success(t *testing.T) { require.Equal(t, notification, m.Message) } -func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) { - c := newTestConfig(t) - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) - notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 507, response.Code) - require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code) -} - -func TestServer_MatrixGateway_Push_Failure_NoSubscriber_After13Hours(t *testing.T) { - c := newTestConfig(t) - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) - notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` - - // No success if no rate visitor set (this also creates the topic in memory) - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 507, response.Code) - require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code) - require.Nil(t, s.topics["mytopic"].rateVisitor) - - // Fake: This topic has been around for 13 hours without a rate visitor - s.topics["mytopic"].lastAccess = time.Now().Add(-13 * time.Hour) - - // Same request should now return HTTP 200 with a rejected pushkey - response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"rejected":["http://127.0.0.1:12345/mytopic?up=1"]}`, strings.TrimSpace(response.Body.String())) - - // Slightly unrelated: Test that topic is pruned after 16 hours - s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour) - s.execManager() - require.Nil(t, s.topics["mytopic"]) -} - func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) { s := newTestServer(t, newTestConfig(t)) notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}` @@ -1444,12 +1197,9 @@ func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) { notification := `{"message":"this is not really a Matrix message"}` response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) require.Equal(t, 400, response.Code) - require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code) - - notification = `this isn't even JSON'` - response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 400, response.Code) - require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 40019, err.Code) + require.Equal(t, 400, err.HTTPCode) } func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) { @@ -1459,7 +1209,9 @@ func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) { notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) require.Equal(t, 500, response.Code) - require.Equal(t, 50003, toHTTPError(t, response.Body.String()).Code) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 50003, err.Code) + require.Equal(t, 500, err.HTTPCode) } func TestServer_PublishActions_AndPoll(t *testing.T) { @@ -1505,24 +1257,7 @@ func TestServer_PublishAsJSON(t *testing.T) { require.True(t, m.Time < time.Now().Unix()+31*60) } -func TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) { - // Publishing as JSON follows a different path. This ensures that rate - // limiting works for this endpoint as well - c := newTestConfig(t) - c.VisitorMessageDailyLimit = 3 - s := newTestServer(t, c) - - for i := 0; i < 3; i++ { - response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil) - require.Equal(t, 200, response.Code) - } - response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil) - require.Equal(t, 429, response.Code) - require.Equal(t, 42908, toHTTPError(t, response.Body.String()).Code) -} - func TestServer_PublishAsJSON_WithEmail(t *testing.T) { - t.Parallel() mailer := &testMailer{} s := newTestServer(t, newTestConfig(t)) s.smtpSender = mailer @@ -1778,7 +1513,6 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t * } func TestServer_PublishAttachmentAndExpire(t *testing.T) { - t.Parallel() content := util.RandomString(5000) // > 4096 c := newTestConfig(t) @@ -1798,16 +1532,14 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) { require.Equal(t, content, response.Body.String()) // Prune and makes sure it's gone - waitFor(t, func() bool { - s.execManager() // May run many times - return !util.FileExists(file) - }) + time.Sleep(time.Second) // Sigh ... + s.execManager() + require.NoFileExists(t, file) response = request(t, s, "GET", path, "", nil) require.Equal(t, 404, response.Code) } func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) { - t.Parallel() content := util.RandomString(5000) // > 4096 c := newTestConfigWithAuthFile(t) @@ -2075,7 +1807,6 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) { } func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { - t.Parallel() count := 50000 c := newTestConfig(t) c.TotalTopicLimit = 50001 @@ -2111,8 +1842,8 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { start = time.Now() response := request(t, s, "PUT", "/mytopic", "some body", nil) m := toMessage(t, response.Body.String()) - require.Equal(t, "some body", m.Message) - require.True(t, time.Since(start) < 100*time.Millisecond) + assert.Equal(t, "some body", m.Message) + assert.True(t, time.Since(start) < 100*time.Millisecond) log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond)) // Wait for all goroutines @@ -2155,433 +1886,6 @@ func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) { require.Equal(t, int64(2), account.Stats.Messages) } -func TestServer_SubscriberRateLimiting_Success(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) - - // "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor - subscriber1Fn := func(r *http.Request) { - r.RemoteAddr = "1.2.3.4" - } - rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{ - "Rate-Topics": "subscriber1topic", - }, subscriber1Fn) - require.Equal(t, 200, rr.Code) - require.Equal(t, "", rr.Body.String()) - require.Equal(t, "1.2.3.4", s.topics["subscriber1topic"].rateVisitor.ip.String()) - - // "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name) - subscriber2Fn := func(r *http.Request) { - r.RemoteAddr = "8.7.7.1" - } - rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn) - require.Equal(t, 200, rr.Code) - require.Equal(t, "", rr.Body.String()) - require.Equal(t, "8.7.7.1", s.topics["up012345678912"].rateVisitor.ip.String()) - - // Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the - // GET request before is also counted towards the request limiter. - for i := 0; i < 2; i++ { - rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil) - require.Equal(t, 200, rr.Code) - } - rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil) - require.Equal(t, 429, rr.Code) - - // Publish another 2 messages to "up012345678912" as visitor 9.9.9.9 - for i := 0; i < 2; i++ { - rr := request(t, s, "PUT", "/up012345678912", "some message", nil) - require.Equal(t, 200, rr.Code) // If we fail here, handlePublish is using the wrong visitor! - } - rr = request(t, s, "PUT", "/up012345678912", "some message", nil) - require.Equal(t, 429, rr.Code) - - // Hurray! At this point, visitor 9.9.9.9 has published 4 messages, even though - // VisitorRequestLimitBurst is 3. That means it's working. - - // Now let's confirm that so far we haven't used up any of visitor 9.9.9.9's request limiter - // by publishing another 3 requests from it. - for i := 0; i < 3; i++ { - rr := request(t, s, "PUT", "/some-other-topic", "some message", nil) - require.Equal(t, 200, rr.Code) - } - rr = request(t, s, "PUT", "/some-other-topic", "some message", nil) - require.Equal(t, 429, rr.Code) -} - -func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorSubscriberRateLimiting = false - s := newTestServer(t, c) - - // Subscriber rate limiting is disabled! - - // Registering visitor 1.2.3.4 to topic has no effect - rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{ - "Rate-Topics": "subscriber1topic", - }, func(r *http.Request) { - r.RemoteAddr = "1.2.3.4" - }) - require.Equal(t, 200, rr.Code) - require.Equal(t, "", rr.Body.String()) - require.Nil(t, s.topics["subscriber1topic"].rateVisitor) - - // Registering visitor 8.7.7.1 to topic has no effect - rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) { - r.RemoteAddr = "8.7.7.1" - }) - require.Equal(t, 200, rr.Code) - require.Equal(t, "", rr.Body.String()) - require.Nil(t, s.topics["up012345678912"].rateVisitor) - - // Publish 3 messages to "subscriber1topic" as visitor 9.9.9.9 - for i := 0; i < 3; i++ { - rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil) - require.Equal(t, 200, rr.Code) - } - rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil) - require.Equal(t, 429, rr.Code) - rr = request(t, s, "PUT", "/up012345678912", "some message", nil) - require.Equal(t, 429, rr.Code) -} - -func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) - - // "Register" 5 different UnifiedPush visitors - for i := 0; i < 5; i++ { - subscriberFn := func(r *http.Request) { - r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1) - } - rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, subscriberFn) - require.Equal(t, 200, rr.Code) - } - - // Publish 2 messages per topic - for i := 0; i < 5; i++ { - for j := 0; j < 2; j++ { - rr := request(t, s, "PUT", fmt.Sprintf("/up12345678901%d?up=1", i), "some message", nil) - require.Equal(t, 200, rr.Code) - } - } -} - -func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) { - c := newTestConfig(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) - - // "Register" 5 different UnifiedPush visitors - for i := 0; i < 5; i++ { - rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, func(r *http.Request) { - r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1) - }) - require.Equal(t, 200, rr.Code) - } - - // Publish 2 messages per topic - for i := 0; i < 5; i++ { - notification := fmt.Sprintf(`{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/up12345678901%d?up=1"}]}}`, i) - for j := 0; j < 2; j++ { - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String()) - } - response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) - require.Equal(t, 429, response.Code, notification) - require.Equal(t, 42901, toHTTPError(t, response.Body.String()).Code) - } -} - -func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) { - c := newTestConfig(t) - c.VisitorRequestLimitBurst = 3 - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) - - // "Register" rate visitor - subscriberFn := func(r *http.Request) { - r.RemoteAddr = "1.2.3.4" - } - rr := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{ - "rate-topics": "mytopic", - }, subscriberFn) - require.Equal(t, 200, rr.Code) - require.Equal(t, "1.2.3.4", s.topics["mytopic"].rateVisitor.ip.String()) - require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor) - - // Publish message, observe rate visitor tokens being decreased - response := request(t, s, "POST", "/mytopic", "some message", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, int64(0), s.visitors["ip:9.9.9.9"].messagesLimiter.Value()) - require.Equal(t, int64(1), s.topics["mytopic"].rateVisitor.messagesLimiter.Value()) - require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor) - - // Expire visitor - s.visitors["ip:1.2.3.4"].seen = time.Now().Add(-1 * 25 * time.Hour) - s.pruneVisitors() - - // Publish message again, observe that rateVisitor is not used anymore and is reset - response = request(t, s, "POST", "/mytopic", "some message", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, int64(1), s.visitors["ip:9.9.9.9"].messagesLimiter.Value()) - require.Nil(t, s.topics["mytopic"].rateVisitor) - require.Nil(t, s.visitors["ip:1.2.3.4"]) -} - -func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionDenyAll - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) - - // Create some ACLs - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "test", - MessageLimit: 5, - })) - require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) - require.Nil(t, s.userManager.ChangeTier("ben", "test")) - require.Nil(t, s.userManager.AllowAccess("ben", "announcements", user.PermissionReadWrite)) - require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) - require.Nil(t, s.userManager.AllowAccess(user.Everyone, "public_topic", user.PermissionReadWrite)) - - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) - require.Nil(t, s.userManager.ChangeTier("phil", "test")) - require.Nil(t, s.userManager.AddReservation("phil", "reserved-for-phil", user.PermissionReadWrite)) - - // Set rate visitor as user "phil" on topic - // - "reserved-for-phil": Allowed, because I am the owner - // - "public_topic": Allowed, because it has read-write permissions for everyone - // - "announcements": NOT allowed, because it has read-only permissions for everyone - rr := request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - "Rate-Topics": "reserved-for-phil,public_topic,announcements", - }) - require.Equal(t, 200, rr.Code) - require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name) - require.Equal(t, "phil", s.topics["public_topic"].rateVisitor.user.Name) - require.Nil(t, s.topics["announcements"].rateVisitor) - - // Set rate visitor as user "ben" on topic - // - "reserved-for-phil": NOT allowed, because I am not the owner - // - "public_topic": Allowed, because it has read-write permissions for everyone - // - "announcements": Allowed, because I have read-write permissions - rr = request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{ - "Authorization": util.BasicAuth("ben", "ben"), - "Rate-Topics": "reserved-for-phil,public_topic,announcements", - }) - require.Equal(t, 200, rr.Code) - require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name) - require.Equal(t, "ben", s.topics["public_topic"].rateVisitor.user.Name) - require.Equal(t, "ben", s.topics["announcements"].rateVisitor.user.Name) -} - -func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.AuthDefault = user.PermissionReadWrite - c.VisitorSubscriberRateLimiting = true - s := newTestServer(t, c) - - // Create some ACLs - require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) - - // Set rate visitor as ip:1.2.3.4 on topic - // - "up123456789012": Allowed, because no ACLs and nobody owns the topic - // - "announcements": NOT allowed, because it has read-only permissions for everyone - rr := request(t, s, "GET", "/up123456789012,announcements/json?poll=1", "", nil, func(r *http.Request) { - r.RemoteAddr = "1.2.3.4" - }) - require.Equal(t, 200, rr.Code) - require.Equal(t, "1.2.3.4", s.topics["up123456789012"].rateVisitor.ip.String()) - require.Nil(t, s.topics["announcements"].rateVisitor) -} - -func TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) { - c := newTestConfig(t) - c.ManagerInterval = 2 * time.Second - s := newTestServer(t, c) - - // Publish some messages, and get stats - for i := 0; i < 5; i++ { - response := request(t, s, "POST", "/mytopic", "some message", nil) - require.Equal(t, 200, response.Code) - } - require.Equal(t, int64(5), s.messages) - require.Equal(t, []int64{0}, s.messagesHistory) - - response := request(t, s, "GET", "/v1/stats", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"messages":5,"messages_rate":0}`+"\n", response.Body.String()) - - // Run manager and see message history update - s.execManager() - require.Equal(t, []int64{0, 5}, s.messagesHistory) - - response = request(t, s, "GET", "/v1/stats", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"messages":5,"messages_rate":2.5}`+"\n", response.Body.String()) // 5 messages in 2 seconds = 2.5 messages per second - - // Publish some more messages - for i := 0; i < 10; i++ { - response := request(t, s, "POST", "/mytopic", "some message", nil) - require.Equal(t, 200, response.Code) - } - require.Equal(t, int64(15), s.messages) - require.Equal(t, []int64{0, 5}, s.messagesHistory) - - response = request(t, s, "GET", "/v1/stats", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"messages":15,"messages_rate":2.5}`+"\n", response.Body.String()) // Rate did not update yet - - // Run manager and see message history update - s.execManager() - require.Equal(t, []int64{0, 5, 15}, s.messagesHistory) - - response = request(t, s, "GET", "/v1/stats", "", nil) - require.Equal(t, 200, response.Code) - require.Equal(t, `{"messages":15,"messages_rate":3.75}`+"\n", response.Body.String()) // 15 messages in 4 seconds = 3.75 messages per second -} - -func TestServer_MessageHistoryMaxSize(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - for i := 0; i < 20; i++ { - s.messages = int64(i) - s.execManager() - } - require.Equal(t, []int64{10, 11, 12, 13, 14, 15, 16, 17, 18, 19}, s.messagesHistory) -} - -func TestServer_MessageCountPersistence(t *testing.T) { - c := newTestConfig(t) - s := newTestServer(t, c) - s.messages = 1234 - s.execManager() - waitFor(t, func() bool { - messages, err := s.messageCache.Stats() - require.Nil(t, err) - return messages == 1234 - }) - - s = newTestServer(t, c) - require.Equal(t, int64(1234), s.messages) -} - -func TestServer_PublishWithUTF8MimeHeader(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - - response := request(t, s, "POST", "/mytopic", "some attachment", map[string]string{ - "X-Filename": "some =?UTF-8?q?=C3=A4?=ttachment.txt", - "X-Message": "=?UTF-8?B?8J+HqfCfh6o=?=", - "X-Title": "=?UTF-8?B?bnRmeSDlvojmo5I=?=, no really I mean it! =?UTF-8?Q?This is q=C3=BC=C3=B6ted-print=C3=A4ble.?=", - "X-Tags": "=?UTF-8?B?8J+HqfCfh6o=?=, =?UTF-8?B?bnRmeSDlvojmo5I=?=", - "X-Click": "=?uTf-8?b?aHR0cHM6Ly/wn5KpLmxh?=", - "X-Actions": "http, \"=?utf-8?q?Mettre =C3=A0 jour?=\", \"https://my.tld/webhook/netbird-update\"; =?utf-8?b?aHR0cCwg6L+Z5piv5LiA5Liq5qCH562+LCBodHRwczovL/CfkqkubGE=?=", - }) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.Equal(t, "🇩🇪", m.Message) - require.Equal(t, "ntfy 很棒, no really I mean it! This is qüöted-printäble.", m.Title) - require.Equal(t, "some ättachment.txt", m.Attachment.Name) - require.Equal(t, "🇩🇪", m.Tags[0]) - require.Equal(t, "ntfy 很棒", m.Tags[1]) - require.Equal(t, "https://💩.la", m.Click) - require.Equal(t, "Mettre à jour", m.Actions[0].Label) - require.Equal(t, "http", m.Actions[1].Action) - require.Equal(t, "这是一个标签", m.Actions[1].Label) - require.Equal(t, "https://💩.la", m.Actions[1].URL) -} - -func TestServer_UpstreamBaseURL_Success(t *testing.T) { - var pollID atomic.Pointer[string] - upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "/87c9cddf7b0105f5fe849bf084c6e600be0fde99be3223335199b4965bd7b735", r.URL.Path) - require.Equal(t, "", string(body)) - require.NotEmpty(t, r.Header.Get("X-Poll-ID")) - pollID.Store(util.String(r.Header.Get("X-Poll-ID"))) - })) - defer upstreamServer.Close() - - c := newTestConfigWithAuthFile(t) - c.BaseURL = "http://myserver.internal" - c.UpstreamBaseURL = upstreamServer.URL - s := newTestServer(t, c) - - // Send message, and wait for upstream server to receive it - response := request(t, s, "PUT", "/mytopic", `hi there`, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.NotEmpty(t, m.ID) - require.Equal(t, "hi there", m.Message) - waitFor(t, func() bool { - pID := pollID.Load() - return pID != nil && *pID == m.ID - }) -} - -func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) { - var pollID atomic.Pointer[string] - upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "/a1c72bcb4daf5af54d13ef86aea8f76c11e8b88320d55f1811d5d7b173bcc1df", r.URL.Path) - require.Equal(t, "Bearer tk_1234567890", r.Header.Get("Authorization")) - require.Equal(t, "", string(body)) - require.NotEmpty(t, r.Header.Get("X-Poll-ID")) - pollID.Store(util.String(r.Header.Get("X-Poll-ID"))) - })) - defer upstreamServer.Close() - - c := newTestConfigWithAuthFile(t) - c.BaseURL = "http://myserver.internal" - c.UpstreamBaseURL = upstreamServer.URL - c.UpstreamAccessToken = "tk_1234567890" - s := newTestServer(t, c) - - // Send message, and wait for upstream server to receive it - response := request(t, s, "PUT", "/mytopic1", `hi there`, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.NotEmpty(t, m.ID) - require.Equal(t, "hi there", m.Message) - waitFor(t, func() bool { - pID := pollID.Load() - return pID != nil && *pID == m.ID - }) -} - -func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) { - upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("UnifiedPush messages should not be forwarded") - })) - defer upstreamServer.Close() - - c := newTestConfigWithAuthFile(t) - c.BaseURL = "http://myserver.internal" - c.UpstreamBaseURL = upstreamServer.URL - s := newTestServer(t, c) - - // Send UP message, this should not forward to upstream server - response := request(t, s, "PUT", "/mytopic?up=1", `hi there`, nil) - require.Equal(t, 200, response.Code) - m := toMessage(t, response.Body.String()) - require.NotEmpty(t, m.ID) - require.Equal(t, "hi there", m.Message) - - // Forwarding is done asynchronously, so wait a bit. - // This ensures that the t.Fatal above is actually not triggered. - time.Sleep(500 * time.Millisecond) -} - func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" @@ -2607,20 +1911,17 @@ func newTestServer(t *testing.T, config *Config) *Server { return server } -func request(t *testing.T, s *Server, method, url, body string, headers map[string]string, fn ...func(r *http.Request)) *httptest.ResponseRecorder { +func request(t *testing.T, s *Server, method, url, body string, headers map[string]string) *httptest.ResponseRecorder { rr := httptest.NewRecorder() - r, err := http.NewRequest(method, url, strings.NewReader(body)) + req, err := http.NewRequest(method, url, strings.NewReader(body)) if err != nil { t.Fatal(err) } - r.RemoteAddr = "9.9.9.9" // Used for tests + req.RemoteAddr = "9.9.9.9" // Used for tests for k, v := range headers { - r.Header.Set(k, v) + req.Header.Set(k, v) } - for _, f := range fn { - f(r) - } - s.handle(rr, r) + s.handle(rr, req) return rr } @@ -2672,18 +1973,3 @@ func readAll(t *testing.T, rc io.ReadCloser) string { } return string(b) } - -func waitFor(t *testing.T, f func() bool) { - waitForWithMaxWait(t, 5*time.Second, f) -} - -func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) { - start := time.Now() - for time.Since(start) < maxWait { - if f() { - return - } - time.Sleep(50 * time.Millisecond) - } - t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack())) -} diff --git a/server/server_twilio.go b/server/server_twilio.go deleted file mode 100644 index 093abe6..0000000 --- a/server/server_twilio.go +++ /dev/null @@ -1,176 +0,0 @@ -package server - -import ( - "bytes" - "encoding/xml" - "fmt" - "heckel.io/ntfy/log" - "heckel.io/ntfy/user" - "heckel.io/ntfy/util" - "io" - "net/http" - "net/url" - "strings" -) - -const ( - twilioCallFormat = ` - - - - You have a message from notify on topic %s. Message: - - %s - - End of message. - - This message was sent by user %s. It will be repeated three times. - To unsubscribe from calls like this, remove your phone number in the notify web app. - - - Goodbye. -` -) - -// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified -// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number. -// If the user is anonymous, it will return an error. -func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) { - if u == nil { - return "", errHTTPBadRequestAnonymousCallsNotAllowed - } - phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) - if err != nil { - return "", errHTTPInternalError - } else if len(phoneNumbers) == 0 { - return "", errHTTPBadRequestPhoneNumberNotVerified - } - if toBool(phoneNumber) { - return phoneNumbers[0], nil - } else if util.Contains(phoneNumbers, phoneNumber) { - return phoneNumber, nil - } - for _, p := range phoneNumbers { - if p == phoneNumber { - return phoneNumber, nil - } - } - return "", errHTTPBadRequestPhoneNumberNotVerified -} - -// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message. -// Failures will be logged, but not returned to the caller. -func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { - u, sender := v.User(), m.Sender.String() - if u != nil { - sender = u.Name - } - body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender)) - data := url.Values{} - data.Set("From", s.config.TwilioPhoneNumber) - data.Set("To", to) - data.Set("Twiml", body) - ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request") - response, err := s.callPhoneInternal(data) - if err != nil { - ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request") - minc(metricCallsMadeFailure) - return - } - ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response") - minc(metricCallsMadeSuccess) -} - -func (s *Server) callPhoneInternal(data url.Values) (string, error) { - requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", s.config.TwilioCallsBaseURL, s.config.TwilioAccount) - req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) - if err != nil { - return "", err - } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", err - } - response, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - return string(response), nil -} - -func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error { - ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification") - data := url.Values{} - data.Set("To", phoneNumber) - data.Set("Channel", channel) - requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService) - req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) - if err != nil { - return err - } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - response, err := io.ReadAll(resp.Body) - if err != nil { - ev.Err(err).Warn("Error sending Twilio phone verification request") - return err - } - ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response") - return nil -} - -func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error { - ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification") - data := url.Values{} - data.Set("To", phoneNumber) - data.Set("Code", code) - requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService) - req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) - if err != nil { - return err - } - req.Header.Set("User-Agent", "ntfy/"+s.config.Version) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } else if resp.StatusCode != http.StatusOK { - if ev.IsTrace() { - response, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - ev.Field("twilio_response", string(response)) - } - ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode) - if resp.StatusCode == http.StatusNotFound { - return errHTTPGonePhoneVerificationExpired - } - return errHTTPInternalError - } - response, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - if ev.IsTrace() { - ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response") - } else if ev.IsDebug() { - ev.Debug("Received successful Twilio phone verification response") - } - return nil -} - -func xmlEscapeText(text string) string { - var buf bytes.Buffer - _ = xml.EscapeText(&buf, []byte(text)) - return buf.String() -} diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go deleted file mode 100644 index af694a7..0000000 --- a/server/server_twilio_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package server - -import ( - "github.com/stretchr/testify/require" - "heckel.io/ntfy/user" - "heckel.io/ntfy/util" - "io" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" -) - -func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { - var called, verified atomic.Bool - var code atomic.Pointer[string] - twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - if r.URL.Path == "/v2/Services/VA1234567890/Verifications" { - if code.Load() != nil { - t.Fatal("Should be only called once") - } - require.Equal(t, "Channel=sms&To=%2B12223334444", string(body)) - code.Store(util.String("123456")) - } else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" { - if verified.Load() { - t.Fatal("Should be only called once") - } - require.Equal(t, "Code=123456&To=%2B12223334444", string(body)) - verified.Store(true) - } else { - t.Fatal("Unexpected path:", r.URL.Path) - } - })) - defer twilioVerifyServer.Close() - twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if called.Load() { - t.Fatal("Should be only called once") - } - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) - require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) - called.Store(true) - })) - defer twilioCallsServer.Close() - - c := newTestConfigWithAuthFile(t) - c.TwilioVerifyBaseURL = twilioVerifyServer.URL - c.TwilioCallsBaseURL = twilioCallsServer.URL - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - c.TwilioVerifyService = "VA1234567890" - s := newTestServer(t, c) - - // Add tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 10, - CallLimit: 1, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - u, err := s.userManager.User("phil") - require.Nil(t, err) - - // Send verification code for phone number - response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - waitFor(t, func() bool { - return *code.Load() == "123456" - }) - - // Add phone number with code - response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - waitFor(t, func() bool { - return verified.Load() - }) - phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) - require.Nil(t, err) - require.Equal(t, 1, len(phoneNumbers)) - require.Equal(t, "+12223334444", phoneNumbers[0]) - - // Do the thing - response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - "x-call": "yes", - }) - require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() - }) - - // Remove the phone number - response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - }) - require.Equal(t, 200, response.Code) - - // Verify the phone number is gone from the DB - phoneNumbers, err = s.userManager.PhoneNumbers(u.ID) - require.Nil(t, err) - require.Equal(t, 0, len(phoneNumbers)) -} - -func TestServer_Twilio_Call_Success(t *testing.T) { - var called atomic.Bool - twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if called.Load() { - t.Fatal("Should be only called once") - } - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) - require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) - called.Store(true) - })) - defer twilioServer.Close() - - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = twilioServer.URL - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) - - // Add tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 10, - CallLimit: 1, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - u, err := s.userManager.User("phil") - require.Nil(t, err) - require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) - - // Do the thing - response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - "x-call": "+11122233344", - }) - require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() - }) -} - -func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { - var called atomic.Bool - twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if called.Load() { - t.Fatal("Should be only called once") - } - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) - require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) - called.Store(true) - })) - defer twilioServer.Close() - - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = twilioServer.URL - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) - - // Add tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 10, - CallLimit: 1, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - u, err := s.userManager.User("phil") - require.Nil(t, err) - require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) - - // Do the thing - response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - "x-call": "yes", // <<<------ - }) - require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() - }) -} - -func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = "http://dummy.invalid" - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) - - // Add tier and user - require.Nil(t, s.userManager.AddTier(&user.Tier{ - Code: "pro", - MessageLimit: 10, - CallLimit: 1, - })) - require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) - require.Nil(t, s.userManager.ChangeTier("phil", "pro")) - - // Do the thing - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "authorization": util.BasicAuth("phil", "phil"), - "x-call": "+11122233344", - }) - require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code) -} - -func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = "https://127.0.0.1" - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) - - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "x-call": "+invalid", - }) - require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code) -} - -func TestServer_Twilio_Call_Anonymous(t *testing.T) { - c := newTestConfigWithAuthFile(t) - c.TwilioCallsBaseURL = "https://127.0.0.1" - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioPhoneNumber = "+1234567890" - s := newTestServer(t, c) - - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "x-call": "+123123", - }) - require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code) -} - -func TestServer_Twilio_Call_Unconfigured(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "x-call": "+1234", - }) - require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code) -} diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 9093687..ee26365 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -4,15 +4,14 @@ import ( _ "embed" // required by go:embed "encoding/json" "fmt" + "heckel.io/ntfy/log" + "heckel.io/ntfy/util" "mime" "net" "net/smtp" "strings" "sync" "time" - - "heckel.io/ntfy/log" - "heckel.io/ntfy/util" ) type mailer interface { @@ -37,10 +36,7 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error { if err != nil { return err } - var auth smtp.Auth - if s.config.SMTPSenderUser != "" { - auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) - } + auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) ev := logvm(v, m). Tag(tagEmail). Fields(log.Context{ @@ -132,23 +128,31 @@ This message was sent by {ip} at {time} via {topicURL}` } var ( - //go:embed "mailer_emoji_map.json" + //go:embed "mailer_emoji.json" emojisJSON string ) +type emoji struct { + Emoji string `json:"emoji"` + Aliases []string `json:"aliases"` +} + func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) { - var emojiMap map[string]string - if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil { + var emojis []emoji + if err = json.Unmarshal([]byte(emojisJSON), &emojis); err != nil { return nil, nil, err } tagsOut = make([]string, 0) emojisOut = make([]string, 0) - for _, t := range tags { - if emoji, ok := emojiMap[t]; ok { - emojisOut = append(emojisOut, emoji) - } else { - tagsOut = append(tagsOut, t) +nextTag: + for _, t := range tags { // TODO Super inefficient; we should just create a .json file with a map + for _, e := range emojis { + if util.Contains(e.Aliases, t) { + emojisOut = append(emojisOut, e.Emoji) + continue nextTag + } } + tagsOut = append(tagsOut, t) } return } diff --git a/server/smtp_server.go b/server/smtp_server.go index b9fbe6e..f6cfb89 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -2,14 +2,12 @@ package server import ( "bytes" - "encoding/base64" "errors" "fmt" "github.com/emersion/go-smtp" "io" "mime" "mime/multipart" - "mime/quotedprintable" "net" "net/http" "net/http/httptest" @@ -23,14 +21,9 @@ var ( errInvalidAddress = errors.New("invalid address") errInvalidTopic = errors.New("invalid topic") errTooManyRecipients = errors.New("too many recipients") - errMultipartNestedTooDeep = errors.New("multipart message nested too deep") errUnsupportedContentType = errors.New("unsupported content type") ) -const ( - maxMultipartDepth = 2 -) - // smtpBackend implements SMTP server methods. type smtpBackend struct { config *Config @@ -66,7 +59,6 @@ type smtpSession struct { backend *smtpBackend conn *smtp.Conn topic string - token string mu sync.Mutex } @@ -83,7 +75,6 @@ func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error { func (s *smtpSession) Rcpt(to string) error { logem(s.conn).Field("smtp_rcpt_to", to).Debug("RCPT TO: %s", to) return s.withFailCount(func() error { - token := "" conf := s.backend.config addressList, err := mail.ParseAddressList(to) if err != nil { @@ -95,27 +86,18 @@ func (s *smtpSession) Rcpt(to string) error { if !strings.HasSuffix(to, "@"+conf.SMTPServerDomain) { return errInvalidDomain } - // Remove @ntfy.sh from end of email to = strings.TrimSuffix(to, "@"+conf.SMTPServerDomain) if conf.SMTPServerAddrPrefix != "" { if !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) { return errInvalidAddress } - // remove ntfy- from beginning of email to = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix) } - // If email contains token, split topic and token - if strings.Contains(to, "+") { - parts := strings.Split(to, "+") - to = parts[0] - token = parts[1] - } if !topicRegex.MatchString(to) { return errInvalidTopic } s.mu.Lock() s.topic = to - s.token = token s.mu.Unlock() return nil }) @@ -138,7 +120,7 @@ func (s *smtpSession) Data(r io.Reader) error { if err != nil { return err } - body, err := readMailBody(msg.Body, msg.Header) + body, err := readMailBody(msg) if err != nil { return err } @@ -166,7 +148,6 @@ func (s *smtpSession) Data(r io.Reader) error { s.backend.mu.Lock() s.backend.success++ s.backend.mu.Unlock() - minc(metricEmailsReceivedSuccess) return nil }) } @@ -177,6 +158,7 @@ func (s *smtpSession) publishMessage(m *message) error { if err != nil { remoteAddr = s.conn.Conn().RemoteAddr().String() } + // Call HTTP handler with fake HTTP request url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic) req, err := http.NewRequest("POST", url, strings.NewReader(m.Message)) @@ -189,9 +171,6 @@ func (s *smtpSession) publishMessage(m *message) error { if m.Title != "" { req.Header.Set("Title", m.Title) } - if s.token != "" { - req.Header.Add("Authorization", "Bearer "+s.token) - } rr := httptest.NewRecorder() s.backend.handler(rr, req) if rr.Code != http.StatusOK { @@ -219,59 +198,52 @@ func (s *smtpSession) withFailCount(fn func() error) error { // We do not want to spam the log with WARN messages. logem(s.conn).Err(err).Debug("Incoming mail error") s.backend.failure++ - minc(metricEmailsReceivedFailure) } return err } -func readMailBody(body io.Reader, header mail.Header) (string, error) { - if header.Get("Content-Type") == "" { - return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding")) +func readMailBody(msg *mail.Message) (string, error) { + if msg.Header.Get("Content-Type") == "" { + return readPlainTextMailBody(msg) } - contentType, params, err := mime.ParseMediaType(header.Get("Content-Type")) + contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) if err != nil { return "", err } - if strings.ToLower(contentType) == "text/plain" { - return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding")) - } else if strings.HasPrefix(strings.ToLower(contentType), "multipart/") { - return readMultipartMailBody(body, params, 0) + if contentType == "text/plain" { + return readPlainTextMailBody(msg) + } else if strings.HasPrefix(contentType, "multipart/") { + return readMultipartMailBody(msg, params) } return "", errUnsupportedContentType } -func readMultipartMailBody(body io.Reader, params map[string]string, depth int) (string, error) { - if depth >= maxMultipartDepth { - return "", errMultipartNestedTooDeep - } - mr := multipart.NewReader(body, params["boundary"]) - for { - part, err := mr.NextPart() - if err != nil { // may be io.EOF - return "", err - } - partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type")) - if err != nil { - return "", err - } - if strings.ToLower(partContentType) == "text/plain" { - return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding")) - } else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") { - return readMultipartMailBody(part, partParams, depth+1) - } - // Continue with next part - } -} - -func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) { - if strings.ToLower(transferEncoding) == "base64" { - reader = base64.NewDecoder(base64.StdEncoding, reader) - } else if strings.ToLower(transferEncoding) == "quoted-printable" { - reader = quotedprintable.NewReader(reader) - } - body, err := io.ReadAll(reader) +func readPlainTextMailBody(msg *mail.Message) (string, error) { + body, err := io.ReadAll(msg.Body) if err != nil { return "", err } return string(body), nil } + +func readMultipartMailBody(msg *mail.Message, params map[string]string) (string, error) { + mr := multipart.NewReader(msg.Body, params["boundary"]) + for { + part, err := mr.NextPart() + if err != nil { // may be io.EOF + return "", err + } + partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type")) + if err != nil { + return "", err + } + if partContentType != "text/plain" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + return "", err + } + return string(body), nil + } +} diff --git a/server/smtp_server_test.go b/server/smtp_server_test.go index 7e1d29d..80aa645 100644 --- a/server/smtp_server_test.go +++ b/server/smtp_server_test.go @@ -303,39 +303,6 @@ BBBBBBBBBBBBBBBBBBBBBBBBB` writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") } -func TestSmtpBackend_Plaintext_QuotedPrintable(t *testing.T) { - email := `EHLO example.com -MAIL FROM: phil@example.com -RCPT TO: mytopic@ntfy.sh -DATA -Date: Tue, 28 Dec 2021 00:30:10 +0100 -Message-ID: -Subject: and one more -From: Phil -To: mytopic@ntfy.sh -Content-Type: text/plain; charset="UTF-8" -Content-Transfer-Encoding: quoted-printable - -what's -=C3=A0&=C3=A9"'(-=C3=A8_=C3=A7=C3=A0) -=3D=3D=3D=3D=3D -up -. -` - s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "and one more", r.Header.Get("Title")) - require.Equal(t, `what's -à&é"'(-è_çà) -===== -up`, readAll(t, r.Body)) - }) - conf.SMTPServerAddrPrefix = "" - defer s.Close() - defer c.Close() - writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") -} - func TestSmtpBackend_Unsupported(t *testing.T) { email := `EHLO example.com MAIL FROM: phil@example.com @@ -381,214 +348,6 @@ what's up writeAndReadUntilLine(t, email, c, scanner, "451 4.0.0 invalid address") } -func TestSmtpBackend_Base64Body(t *testing.T) { - email := `EHLO example.com -MAIL FROM: test@mydomain.me -RCPT TO: ntfy-mytopic@ntfy.sh -DATA -Content-Type: multipart/mixed; boundary="===============2138658284696597373==" -MIME-Version: 1.0 -Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local -From: =?utf-8?q?Robbie?= -To: test@mydomain.me -Date: Thu, 16 Feb 2023 01:04:00 -0000 -Message-ID: - -This is a multi-part message in MIME format. ---===============2138658284696597373== -Content-Type: text/plain; charset="utf-8" -MIME-Version: 1.0 -Content-Transfer-Encoding: base64 - -VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4= - ---===============2138658284696597373== -Content-Type: text/html; charset="utf-8" -MIME-Version: 1.0 -Content-Transfer-Encoding: base64 - -PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv -L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg== - ---===============2138658284696597373==-- -. -` - s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title")) - require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body)) - }) - defer s.Close() - defer c.Close() - writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") -} - -func TestSmtpBackend_MultipartQuotedPrintable(t *testing.T) { - email := `EHLO example.com -MAIL FROM: phil@example.com -RCPT TO: ntfy-mytopic@ntfy.sh -DATA -MIME-Version: 1.0 -Date: Tue, 28 Dec 2021 00:30:10 +0100 -Message-ID: -Subject: and one more -From: Phil -To: ntfy-mytopic@ntfy.sh -Content-Type: multipart/alternative; boundary="000000000000f3320b05d42915c9" - ---000000000000f3320b05d42915c9 -Content-Type: text/html; charset="UTF-8" - -html, ignore me - ---000000000000f3320b05d42915c9 -Content-Type: text/plain; charset="UTF-8" -Content-Transfer-Encoding: quoted-printable - -what's -=C3=A0&=C3=A9"'(-=C3=A8_=C3=A7=C3=A0) -=3D=3D=3D=3D=3D -up - ---000000000000f3320b05d42915c9-- -. -` - s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "and one more", r.Header.Get("Title")) - require.Equal(t, `what's -à&é"'(-è_çà) -===== -up`, readAll(t, r.Body)) - }) - defer s.Close() - defer c.Close() - writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") -} - -func TestSmtpBackend_NestedMultipartBase64(t *testing.T) { - email := `EHLO example.com -MAIL FROM: test@mydomain.me -RCPT TO: ntfy-mytopic@ntfy.sh -DATA -Content-Type: multipart/mixed; boundary="===============2138658284696597373==" -MIME-Version: 1.0 -Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local -From: =?utf-8?q?Robbie?= -To: test@mydomain.me -Date: Thu, 16 Feb 2023 01:04:00 -0000 -Message-ID: - -This is a multi-part message in MIME format. ---===============2138658284696597373== -Content-Type: multipart/alternative; boundary="===============2233989480071754745==" -MIME-Version: 1.0 - ---===============2233989480071754745== -Content-Type: text/plain; charset="utf-8" -MIME-Version: 1.0 -Content-Transfer-Encoding: base64 - -VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4= - ---===============2233989480071754745== -Content-Type: text/html; charset="utf-8" -MIME-Version: 1.0 -Content-Transfer-Encoding: base64 - -PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv -L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg== - ---===============2233989480071754745==-- - ---===============2138658284696597373==-- -. -` - - s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title")) - require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body)) - }) - defer s.Close() - defer c.Close() - writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") -} - -func TestSmtpBackend_NestedMultipartTooDeep(t *testing.T) { - email := `EHLO example.com -MAIL FROM: test@mydomain.me -RCPT TO: ntfy-mytopic@ntfy.sh -DATA -Content-Type: multipart/mixed; boundary="===============1==" -MIME-Version: 1.0 -Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local -From: =?utf-8?q?Robbie?= -To: test@mydomain.me -Date: Thu, 16 Feb 2023 01:04:00 -0000 -Message-ID: - -This is a multi-part message in MIME format. ---===============1== -Content-Type: multipart/alternative; boundary="===============2==" -MIME-Version: 1.0 - ---===============2== -Content-Type: multipart/alternative; boundary="===============3==" -MIME-Version: 1.0 - ---===============3== -Content-Type: text/plain; charset="utf-8" -MIME-Version: 1.0 -Content-Transfer-Encoding: base64 - -VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4= - ---===============3== -Content-Type: text/html; charset="utf-8" -MIME-Version: 1.0 -Content-Transfer-Encoding: base64 - -PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv -L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg== - ---===============3==-- - ---===============2==-- - ---===============1==-- -. -` - - s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("This should not be called") - }) - defer s.Close() - defer c.Close() - writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep") -} - -func TestSmtpBackend_PlaintextWithToken(t *testing.T) { - email := `EHLO example.com -MAIL FROM: phil@example.com -RCPT TO: ntfy-mytopic+tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2@ntfy.sh -DATA -Subject: Very short mail - -what's up -. -` - s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/mytopic", r.URL.Path) - require.Equal(t, "Very short mail", r.Header.Get("Title")) - require.Equal(t, "Bearer tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2", r.Header.Get("Authorization")) - require.Equal(t, "what's up", readAll(t, r.Body)) - }) - defer s.Close() - defer c.Close() - writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") -} - type smtpHandlerFunc func(http.ResponseWriter, *http.Request) func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) { diff --git a/server/topic.go b/server/topic.go index 5dfafbe..150a185 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1,19 +1,9 @@ package server import ( + "heckel.io/ntfy/log" "math/rand" "sync" - "time" - - "heckel.io/ntfy/log" - "heckel.io/ntfy/util" -) - -const ( - // topicExpungeAfter defines how long a topic is active before it is removed from memory. - // This must be larger than matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter to give - // time for more requests to come in, so that we can send a {"rejected":[""]} response back. - topicExpungeAfter = 16 * time.Hour ) // topic represents a channel to which subscribers can subscribe, and publishers @@ -21,9 +11,7 @@ const ( type topic struct { ID string subscribers map[int]*topicSubscriber - rateVisitor *visitor - lastAccess time.Time - mu sync.RWMutex + mu sync.Mutex } type topicSubscriber struct { @@ -40,61 +28,22 @@ func newTopic(id string) *topic { return &topic{ ID: id, subscribers: make(map[int]*topicSubscriber), - lastAccess: time.Now(), } } // Subscribe subscribes to this topic -func (t *topic) Subscribe(s subscriber, userID string, cancel func()) (subscriberID int) { +func (t *topic) Subscribe(s subscriber, userID string, cancel func()) int { t.mu.Lock() defer t.mu.Unlock() - for i := 0; i < 5; i++ { // Best effort retry - subscriberID = rand.Int() - _, exists := t.subscribers[subscriberID] - if !exists { - break - } - } + subscriberID := rand.Int() t.subscribers[subscriberID] = &topicSubscriber{ userID: userID, // May be empty subscriber: s, cancel: cancel, } - t.lastAccess = time.Now() return subscriberID } -func (t *topic) Stale() bool { - t.mu.Lock() - defer t.mu.Unlock() - if t.rateVisitor != nil && !t.rateVisitor.Stale() { - return false - } - return len(t.subscribers) == 0 && time.Since(t.lastAccess) > topicExpungeAfter -} - -func (t *topic) LastAccess() time.Time { - t.mu.RLock() - defer t.mu.RUnlock() - return t.lastAccess -} - -func (t *topic) SetRateVisitor(v *visitor) { - t.mu.Lock() - defer t.mu.Unlock() - t.rateVisitor = v - t.lastAccess = time.Now() -} - -func (t *topic) RateVisitor() *visitor { - t.mu.Lock() - defer t.mu.Unlock() - if t.rateVisitor != nil && t.rateVisitor.Stale() { - t.rateVisitor = nil - } - return t.rateVisitor -} - // Unsubscribe removes the subscription from the list of subscribers func (t *topic) Unsubscribe(id int) { t.mu.Lock() @@ -122,75 +71,29 @@ func (t *topic) Publish(v *visitor, m *message) error { } else { logvm(v, m).Tag(tagPublish).Trace("No stream or WebSocket subscribers, not forwarding") } - t.Keepalive() }() return nil } -// Stats returns the number of subscribers and last access to this topic -func (t *topic) Stats() (int, time.Time) { - t.mu.RLock() - defer t.mu.RUnlock() - return len(t.subscribers), t.lastAccess -} - -// Keepalive sets the last access time and ensures that Stale does not return true -func (t *topic) Keepalive() { +// SubscribersCount returns the number of subscribers to this topic +func (t *topic) SubscribersCount() int { t.mu.Lock() defer t.mu.Unlock() - t.lastAccess = time.Now() + return len(t.subscribers) } -// CancelSubscribersExceptUser calls the cancel function for all subscribers, forcing -func (t *topic) CancelSubscribersExceptUser(exceptUserID string) { +// CancelSubscribers calls the cancel function for all subscribers, forcing +func (t *topic) CancelSubscribers(exceptUserID string) { t.mu.Lock() defer t.mu.Unlock() for _, s := range t.subscribers { if s.userID != exceptUserID { - t.cancelUserSubscriber(s) + log.Tag(tagSubscribe).Field("topic", t.ID).Debug("Canceling subscriber %s", s.userID) + s.cancel() } } } -// CancelSubscriberUser kills the subscriber with the given user ID -func (t *topic) CancelSubscriberUser(userID string) { - t.mu.RLock() - defer t.mu.RUnlock() - for _, s := range t.subscribers { - if s.userID == userID { - t.cancelUserSubscriber(s) - return - } - } -} - -func (t *topic) cancelUserSubscriber(s *topicSubscriber) { - log. - Tag(tagSubscribe). - With(t). - Fields(log.Context{ - "user_id": s.userID, - }). - Debug("Canceling subscriber with user ID %s", s.userID) - s.cancel() -} - -func (t *topic) Context() log.Context { - t.mu.RLock() - defer t.mu.RUnlock() - fields := map[string]any{ - "topic": t.ID, - "topic_subscribers": len(t.subscribers), - "topic_last_access": util.FormatTime(t.lastAccess), - } - if t.rateVisitor != nil { - for k, v := range t.rateVisitor.Context() { - fields["topic_rate_"+k] = v - } - } - return fields -} - // subscribersCopy returns a shallow copy of the subscribers map func (t *topic) subscribersCopy() map[int]*topicSubscriber { t.mu.Lock() diff --git a/server/topic_test.go b/server/topic_test.go deleted file mode 100644 index 41a29cf..0000000 --- a/server/topic_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package server - -import ( - "math/rand" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestTopic_CancelSubscribersExceptUser(t *testing.T) { - t.Parallel() - - subFn := func(v *visitor, msg *message) error { - return nil - } - canceled1 := atomic.Bool{} - cancelFn1 := func() { - canceled1.Store(true) - } - canceled2 := atomic.Bool{} - cancelFn2 := func() { - canceled2.Store(true) - } - to := newTopic("mytopic") - to.Subscribe(subFn, "", cancelFn1) - to.Subscribe(subFn, "u_phil", cancelFn2) - - to.CancelSubscribersExceptUser("u_phil") - require.True(t, canceled1.Load()) - require.False(t, canceled2.Load()) -} - -func TestTopic_CancelSubscribersUser(t *testing.T) { - t.Parallel() - - subFn := func(v *visitor, msg *message) error { - return nil - } - canceled1 := atomic.Bool{} - cancelFn1 := func() { - canceled1.Store(true) - } - canceled2 := atomic.Bool{} - cancelFn2 := func() { - canceled2.Store(true) - } - to := newTopic("mytopic") - to.Subscribe(subFn, "u_another", cancelFn1) - to.Subscribe(subFn, "u_phil", cancelFn2) - - to.CancelSubscriberUser("u_phil") - require.False(t, canceled1.Load()) - require.True(t, canceled2.Load()) -} - -func TestTopic_Keepalive(t *testing.T) { - t.Parallel() - - to := newTopic("mytopic") - to.lastAccess = time.Now().Add(-1 * time.Hour) - to.Keepalive() - require.True(t, to.LastAccess().Unix() >= time.Now().Unix()-2) - require.True(t, to.LastAccess().Unix() <= time.Now().Unix()+2) -} - -func TestTopic_Subscribe_DuplicateID(t *testing.T) { - t.Parallel() - to := newTopic("mytopic") - - // Fix random seed to force same number generation - rand.Seed(1) - a := rand.Int() - to.subscribers[a] = &topicSubscriber{ - userID: "a", - subscriber: nil, - cancel: func() {}, - } - - subFn := func(v *visitor, msg *message) error { - return nil - } - - // Force rand.Int to generate the same id once more - rand.Seed(1) - id := to.Subscribe(subFn, "b", func() {}) - res := to.subscribers[id] - - require.NotEqual(t, id, a) - require.Equal(t, "b", res.userID, "b") -} diff --git a/server/types.go b/server/types.go index 9e4ff55..c633135 100644 --- a/server/types.go +++ b/server/types.go @@ -45,10 +45,10 @@ type message struct { func (m *message) Context() log.Context { fields := map[string]any{ - "topic": m.Topic, "message_id": m.ID, "message_time": m.Time, "message_event": m.Event, + "message_topic": m.Topic, "message_body_size": len(m.Message), } if m.Sender.IsValid() { @@ -101,7 +101,6 @@ type publishMessage struct { Attach string `json:"attach"` Filename string `json:"filename"` Email string `json:"email"` - Call string `json:"call"` Delay string `json:"delay"` } @@ -240,45 +239,6 @@ type apiHealthResponse struct { Healthy bool `json:"healthy"` } -type apiStatsResponse struct { - Messages int64 `json:"messages"` - MessagesRate float64 `json:"messages_rate"` // Average number of messages per second -} - -type apiUserAddRequest struct { - Username string `json:"username"` - Password string `json:"password"` - Tier string `json:"tier"` - // Do not add 'role' here. We don't want to add admins via the API. -} - -type apiUserResponse struct { - Username string `json:"username"` - Role string `json:"role"` - Tier string `json:"tier,omitempty"` - Grants []*apiUserGrantResponse `json:"grants,omitempty"` -} - -type apiUserGrantResponse struct { - Topic string `json:"topic"` // This may be a pattern - Permission string `json:"permission"` -} - -type apiUserDeleteRequest struct { - Username string `json:"username"` -} - -type apiAccessAllowRequest struct { - Username string `json:"username"` - Topic string `json:"topic"` // This may be a pattern - Permission string `json:"permission"` -} - -type apiAccessResetRequest struct { - Username string `json:"username"` - Topic string `json:"topic"` -} - type apiAccountCreateRequest struct { Username string `json:"username"` Password string `json:"password"` @@ -312,16 +272,6 @@ type apiAccountTokenResponse struct { Expires int64 `json:"expires,omitempty"` // Unix timestamp } -type apiAccountPhoneNumberVerifyRequest struct { - Number string `json:"number"` - Channel string `json:"channel"` -} - -type apiAccountPhoneNumberAddRequest struct { - Number string `json:"number"` - Code string `json:"code"` // Only set when adding a phone number -} - type apiAccountTier struct { Code string `json:"code"` Name string `json:"name"` @@ -332,7 +282,6 @@ type apiAccountLimits struct { Messages int64 `json:"messages"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"` Emails int64 `json:"emails"` - Calls int64 `json:"calls"` Reservations int64 `json:"reservations"` AttachmentTotalSize int64 `json:"attachment_total_size"` AttachmentFileSize int64 `json:"attachment_file_size"` @@ -345,8 +294,6 @@ type apiAccountStats struct { MessagesRemaining int64 `json:"messages_remaining"` Emails int64 `json:"emails"` EmailsRemaining int64 `json:"emails_remaining"` - Calls int64 `json:"calls"` - CallsRemaining int64 `json:"calls_remaining"` Reservations int64 `json:"reservations"` ReservationsRemaining int64 `json:"reservations_remaining"` AttachmentTotalSize int64 `json:"attachment_total_size"` @@ -362,7 +309,6 @@ type apiAccountBilling struct { Customer bool `json:"customer"` Subscription bool `json:"subscription"` Status string `json:"status,omitempty"` - Interval string `json:"interval,omitempty"` PaidUntil int64 `json:"paid_until,omitempty"` CancelAt int64 `json:"cancel_at,omitempty"` } @@ -376,7 +322,6 @@ type apiAccountResponse struct { Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` Reservations []*apiAccountReservation `json:"reservations,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` - PhoneNumbers []string `json:"phone_numbers,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"` @@ -394,23 +339,15 @@ type apiConfigResponse struct { EnableLogin bool `json:"enable_login"` EnableSignup bool `json:"enable_signup"` EnablePayments bool `json:"enable_payments"` - EnableCalls bool `json:"enable_calls"` - EnableEmails bool `json:"enable_emails"` EnableReservations bool `json:"enable_reservations"` - BillingContact string `json:"billing_contact"` DisallowedTopics []string `json:"disallowed_topics"` } -type apiAccountBillingPrices struct { - Month int64 `json:"month"` - Year int64 `json:"year"` -} - type apiAccountBillingTier struct { - Code string `json:"code,omitempty"` - Name string `json:"name,omitempty"` - Prices *apiAccountBillingPrices `json:"prices,omitempty"` - Limits *apiAccountLimits `json:"limits"` + Code string `json:"code,omitempty"` + Name string `json:"name,omitempty"` + Price string `json:"price,omitempty"` + Limits *apiAccountLimits `json:"limits"` } type apiAccountBillingSubscriptionCreateResponse struct { @@ -418,8 +355,7 @@ type apiAccountBillingSubscriptionCreateResponse struct { } type apiAccountBillingSubscriptionChangeRequest struct { - Tier string `json:"tier"` - Interval string `json:"interval"` + Tier string `json:"tier"` } type apiAccountBillingPortalRedirectResponse struct { @@ -449,10 +385,7 @@ type apiStripeSubscriptionUpdatedEvent struct { Items *struct { Data []*struct { Price *struct { - ID string `json:"id"` - Recurring *struct { - Interval string `json:"interval"` - } `json:"recurring"` + ID string `json:"id"` } `json:"price"` } `json:"data"` } `json:"items"` diff --git a/server/util.go b/server/util.go index 03eb866..99c5e1b 100644 --- a/server/util.go +++ b/server/util.go @@ -1,45 +1,24 @@ package server import ( - "context" - "fmt" + "bufio" "heckel.io/ntfy/util" "io" - "mime" + "net" "net/http" "net/netip" "strings" + "sync" ) -var mimeDecoder mime.WordDecoder - func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { value := strings.ToLower(readParam(r, names...)) if value == "" { return defaultValue } - return toBool(value) -} - -func isBoolValue(value string) bool { - return value == "1" || value == "yes" || value == "true" || value == "0" || value == "no" || value == "false" -} - -func toBool(value string) bool { return value == "1" || value == "yes" || value == "true" } -func readCommaSeparatedParam(r *http.Request, names ...string) (params []string) { - paramStr := readParam(r, names...) - if paramStr != "" { - params = make([]string, 0) - for _, s := range util.SplitNoEmpty(paramStr, ",") { - params = append(params, strings.TrimSpace(s)) - } - } - return params -} - func readParam(r *http.Request, names ...string) string { value := readHeaderParam(r, names...) if value != "" { @@ -50,7 +29,7 @@ func readParam(r *http.Request, names ...string) string { func readHeaderParam(r *http.Request, names ...string) string { for _, name := range names { - value := maybeDecodeHeader(r.Header.Get(name)) + value := r.Header.Get(name) if value != "" { return strings.TrimSpace(value) } @@ -110,26 +89,56 @@ func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, return obj, nil } -func withContext(r *http.Request, ctx map[contextKey]any) *http.Request { - c := r.Context() - for k, v := range ctx { - c = context.WithValue(c, k, v) - } - return r.WithContext(c) +type httpResponseWriter struct { + w http.ResponseWriter + headerWritten bool + mu sync.Mutex } -func fromContext[T any](r *http.Request, key contextKey) (T, error) { - t, ok := r.Context().Value(key).(T) - if !ok { - return t, fmt.Errorf("cannot find key %v in request context", key) - } - return t, nil +type httpResponseWriterWithHijacker struct { + httpResponseWriter } -func maybeDecodeHeader(header string) string { - decoded, err := mimeDecoder.DecodeHeader(header) - if err != nil { - return header +var _ http.ResponseWriter = (*httpResponseWriter)(nil) +var _ http.Flusher = (*httpResponseWriter)(nil) +var _ http.Hijacker = (*httpResponseWriterWithHijacker)(nil) + +func newHTTPResponseWriter(w http.ResponseWriter) http.ResponseWriter { + if _, ok := w.(http.Hijacker); ok { + return &httpResponseWriterWithHijacker{httpResponseWriter: httpResponseWriter{w: w}} } - return decoded + return &httpResponseWriter{w: w} +} + +func (w *httpResponseWriter) Header() http.Header { + return w.w.Header() +} + +func (w *httpResponseWriter) Write(bytes []byte) (int, error) { + w.mu.Lock() + w.headerWritten = true + w.mu.Unlock() + return w.w.Write(bytes) +} + +func (w *httpResponseWriter) WriteHeader(statusCode int) { + w.mu.Lock() + if w.headerWritten { + w.mu.Unlock() + return + } + w.headerWritten = true + w.mu.Unlock() + w.w.WriteHeader(statusCode) +} + +func (w *httpResponseWriter) Flush() { + if f, ok := w.w.(http.Flusher); ok { + f.Flush() + } +} + +func (w *httpResponseWriterWithHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { + h, _ := w.w.(http.Hijacker) + return h.Hijack() } diff --git a/server/visitor.go b/server/visitor.go index e4c06f6..04bd822 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -24,10 +24,6 @@ const ( // visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve. // This number is zero, and changing it may have unintended consequences in the web app, or otherwise visitorDefaultReservationsLimit = int64(0) - - // visitorDefaultCallsLimit is the amount of calls a user without a tier is allowed to make. - // This number is zero, because phone numbers have to be verified first. - visitorDefaultCallsLimit = int64(0) ) // Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter @@ -60,7 +56,6 @@ type visitor struct { requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) messagesLimiter *util.FixedLimiter // Rate limiter for messages emailsLimiter *util.RateLimiter // Rate limiter for emails - callsLimiter *util.FixedLimiter // Rate limiter for calls subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil @@ -84,7 +79,6 @@ type visitorLimits struct { EmailLimit int64 EmailLimitBurst int EmailLimitReplenish rate.Limit - CallLimit int64 ReservationsLimit int64 AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 @@ -97,8 +91,6 @@ type visitorStats struct { MessagesRemaining int64 Emails int64 EmailsRemaining int64 - Calls int64 - CallsRemaining int64 Reservations int64 ReservationsRemaining int64 AttachmentTotalSize int64 @@ -115,11 +107,10 @@ const ( ) func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { - var messages, emails, calls int64 + var messages, emails int64 if user != nil { messages = user.Stats.Messages emails = user.Stats.Emails - calls = user.Stats.Calls } v := &visitor{ config: conf, @@ -133,12 +124,11 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana requestLimiter: nil, // Set in resetLimiters messagesLimiter: nil, // Set in resetLimiters, may be nil emailsLimiter: nil, // Set in resetLimiters - callsLimiter: nil, // Set in resetLimiters, may be nil bandwidthLimiter: nil, // Set in resetLimiters accountLimiter: nil, // Set in resetLimiters, may be nil authLimiter: nil, // Set in resetLimiters, may be nil } - v.resetLimitersNoLock(messages, emails, calls, false) + v.resetLimitersNoLock(messages, emails, false) return v } @@ -151,25 +141,16 @@ func (v *visitor) Context() log.Context { func (v *visitor) contextNoLock() log.Context { info := v.infoLightNoLock() fields := log.Context{ - "visitor_id": visitorID(v.ip, v.user), "visitor_ip": v.ip.String(), - "visitor_seen": util.FormatTime(v.seen), "visitor_messages": info.Stats.Messages, "visitor_messages_limit": info.Limits.MessageLimit, "visitor_messages_remaining": info.Stats.MessagesRemaining, + "visitor_emails": info.Stats.Emails, + "visitor_emails_limit": info.Limits.EmailLimit, + "visitor_emails_remaining": info.Stats.EmailsRemaining, "visitor_request_limiter_limit": v.requestLimiter.Limit(), "visitor_request_limiter_tokens": v.requestLimiter.Tokens(), } - if v.config.SMTPSenderFrom != "" { - fields["visitor_emails"] = info.Stats.Emails - fields["visitor_emails_limit"] = info.Limits.EmailLimit - fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining - } - if v.config.TwilioAccount != "" { - fields["visitor_calls"] = info.Stats.Calls - fields["visitor_calls_limit"] = info.Limits.CallLimit - fields["visitor_calls_remaining"] = info.Stats.CallsRemaining - } if v.authLimiter != nil { fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit() fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens() @@ -233,12 +214,6 @@ func (v *visitor) EmailAllowed() bool { return v.emailsLimiter.Allow() } -func (v *visitor) CallAllowed() bool { - v.mu.RLock() // limiters could be replaced! - defer v.mu.RUnlock() - return v.callsLimiter.Allow() -} - func (v *visitor) SubscriptionAllowed() bool { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() @@ -319,7 +294,6 @@ func (v *visitor) Stats() *user.Stats { return &user.Stats{ Messages: v.messagesLimiter.Value(), Emails: v.emailsLimiter.Value(), - Calls: v.callsLimiter.Value(), } } @@ -328,7 +302,6 @@ func (v *visitor) ResetStats() { defer v.mu.RUnlock() v.emailsLimiter.Reset() v.messagesLimiter.Reset() - v.callsLimiter.Reset() } // User returns the visitor user, or nil if there is none @@ -357,13 +330,9 @@ func (v *visitor) SetUser(u *user.User) { v.mu.Lock() defer v.mu.Unlock() shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver - v.user = u // u may be nil! + v.user = u if shouldResetLimiters { - var messages, emails, calls int64 - if u != nil { - messages, emails, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.Calls - } - v.resetLimitersNoLock(messages, emails, calls, true) + v.resetLimitersNoLock(u.Stats.Messages, u.Stats.Emails, true) } } @@ -378,12 +347,11 @@ func (v *visitor) MaybeUserID() string { return "" } -func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpdate bool) { +func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool) { limits := v.limitsNoLock() v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst) v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages) v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails) - v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls) v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay) if v.user == nil { v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst) @@ -396,7 +364,6 @@ func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpda go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{ Messages: messages, Emails: emails, - Calls: calls, }) } log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters @@ -425,7 +392,6 @@ func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits { EmailLimit: tier.EmailLimit, EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax), EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit), - CallLimit: tier.CallLimit, ReservationsLimit: tier.ReservationLimit, AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit, AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit, @@ -448,7 +414,6 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits { EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! EmailLimitBurst: conf.VisitorEmailLimitBurst, EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), - CallLimit: visitorDefaultCallsLimit, ReservationsLimit: visitorDefaultReservationsLimit, AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit, AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit, @@ -494,15 +459,12 @@ func (v *visitor) Info() (*visitorInfo, error) { func (v *visitor) infoLightNoLock() *visitorInfo { messages := v.messagesLimiter.Value() emails := v.emailsLimiter.Value() - calls := v.callsLimiter.Value() limits := v.limitsNoLock() stats := &visitorStats{ Messages: messages, MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages), Emails: emails, EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails), - Calls: calls, - CallsRemaining: zeroIfNegative(limits.CallLimit - calls), } return &visitorInfo{ Limits: limits, diff --git a/tools/loadgen/main.go b/tools/loadgen/main.go deleted file mode 100644 index 4ce201d..0000000 --- a/tools/loadgen/main.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "bufio" - "context" - "fmt" - "net/http" - "os" - "time" -) - -func main() { - baseURL := "https://staging.ntfy.sh" - if len(os.Args) > 1 { - baseURL = os.Args[1] - } - for i := 0; i < 2000; i++ { - go subscribe(i, baseURL) - } - time.Sleep(5 * time.Second) - for i := 0; i < 2000; i++ { - go func(worker int) { - for { - poll(worker, baseURL) - } - }(i) - } - time.Sleep(time.Hour) -} - -func subscribe(worker int, baseURL string) { - fmt.Printf("[subscribe] worker=%d STARTING\n", worker) - start := time.Now() - topic, ip := fmt.Sprintf("subtopic%d", worker), fmt.Sprintf("1.2.%d.%d", (worker/255)%255, worker%255) - req, _ := http.NewRequest("GET", fmt.Sprintf("%s/%s/json", baseURL, topic), nil) - req.Header.Set("X-Forwarded-For", ip) - resp, err := http.DefaultClient.Do(req) - if err != nil { - fmt.Printf("[subscribe] worker=%d time=%d error=%s\n", worker, time.Since(start).Milliseconds(), err.Error()) - return - } - defer resp.Body.Close() - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - // Do nothing - } - fmt.Printf("[subscribe] worker=%d status=%d time=%d EXITED\n", worker, resp.StatusCode, time.Since(start).Milliseconds()) -} - -func poll(worker int, baseURL string) { - fmt.Printf("[poll] worker=%d STARTING\n", worker) - topic, ip := fmt.Sprintf("polltopic%d", worker), fmt.Sprintf("1.2.%d.%d", (worker/255)%255, worker%255) - start := time.Now() - ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) - defer cancel() - - //req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://staging.ntfy.sh/%s/json?poll=1&since=all", topic), nil) - req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/%s/json?poll=1&since=all", baseURL, topic), nil) - req.Header.Set("X-Forwarded-For", ip) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - fmt.Printf("[poll] worker=%d time=%d status=- error=%s\n", worker, time.Since(start).Milliseconds(), err.Error()) - cancel() - return - } - defer resp.Body.Close() - fmt.Printf("[poll] worker=%d time=%d status=%s\n", worker, time.Since(start).Milliseconds(), resp.Status) -} diff --git a/user/manager.go b/user/manager.go index 00407ab..bb0dc3f 100644 --- a/user/manager.go +++ b/user/manager.go @@ -6,7 +6,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/mattn/go-sqlite3" + _ "github.com/mattn/go-sqlite3" // SQLite driver "github.com/stripe/stripe-go/v74" "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/log" @@ -46,278 +46,7 @@ var ( // Manager-related queries const ( - createTablesQueries = ` - BEGIN; - CREATE TABLE IF NOT EXISTS tier ( - id TEXT PRIMARY KEY, - code TEXT NOT NULL, - name TEXT NOT NULL, - messages_limit INT NOT NULL, - messages_expiry_duration INT NOT NULL, - emails_limit INT NOT NULL, - calls_limit INT NOT NULL, - reservations_limit INT NOT NULL, - attachment_file_size_limit INT NOT NULL, - attachment_total_size_limit INT NOT NULL, - attachment_expiry_duration INT NOT NULL, - attachment_bandwidth_limit INT NOT NULL, - stripe_monthly_price_id TEXT, - stripe_yearly_price_id TEXT - ); - CREATE UNIQUE INDEX idx_tier_code ON tier (code); - CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); - CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); - CREATE TABLE IF NOT EXISTS user ( - id TEXT PRIMARY KEY, - tier_id TEXT, - user TEXT NOT NULL, - pass TEXT NOT NULL, - role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, - prefs JSON NOT NULL DEFAULT '{}', - sync_topic TEXT NOT NULL, - stats_messages INT NOT NULL DEFAULT (0), - stats_emails INT NOT NULL DEFAULT (0), - stats_calls INT NOT NULL DEFAULT (0), - stripe_customer_id TEXT, - stripe_subscription_id TEXT, - stripe_subscription_status TEXT, - stripe_subscription_interval TEXT, - stripe_subscription_paid_until INT, - stripe_subscription_cancel_at INT, - created INT NOT NULL, - deleted INT, - FOREIGN KEY (tier_id) REFERENCES tier (id) - ); - CREATE UNIQUE INDEX idx_user ON user (user); - CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id); - CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id); - CREATE TABLE IF NOT EXISTS user_access ( - user_id TEXT NOT NULL, - topic TEXT NOT NULL, - read INT NOT NULL, - write INT NOT NULL, - owner_user_id INT, - PRIMARY KEY (user_id, topic), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, - FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS user_token ( - user_id TEXT NOT NULL, - token TEXT NOT NULL, - label TEXT NOT NULL, - last_access INT NOT NULL, - last_origin TEXT NOT NULL, - expires INT NOT NULL, - PRIMARY KEY (user_id, token), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS user_phone ( - user_id TEXT NOT NULL, - phone_number TEXT NOT NULL, - PRIMARY KEY (user_id, phone_number), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS schemaVersion ( - id INT PRIMARY KEY, - version INT NOT NULL - ); - INSERT INTO user (id, user, pass, role, sync_topic, created) - VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH()) - ON CONFLICT (id) DO NOTHING; - COMMIT; - ` - builtinStartupQueries = ` - PRAGMA foreign_keys = ON; - ` - - selectUserByIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id - FROM user u - LEFT JOIN tier t on t.id = u.tier_id - WHERE u.id = ? - ` - selectUserByNameQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id - FROM user u - LEFT JOIN tier t on t.id = u.tier_id - WHERE user = ? - ` - selectUserByTokenQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id - FROM user u - JOIN user_token tk on u.id = tk.user_id - LEFT JOIN tier t on t.id = u.tier_id - WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) - ` - selectUserByStripeCustomerIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id - FROM user u - LEFT JOIN tier t on t.id = u.tier_id - WHERE u.stripe_customer_id = ? - ` - selectTopicPermsQuery = ` - SELECT read, write - FROM user_access a - JOIN user u ON u.id = a.user_id - WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic - ORDER BY u.user DESC - ` - - insertUserQuery = ` - INSERT INTO user (id, user, pass, role, sync_topic, created) - VALUES (?, ?, ?, ?, ?, ?) - ` - selectUsernamesQuery = ` - SELECT user - FROM user - ORDER BY - CASE role - WHEN 'admin' THEN 1 - WHEN 'anonymous' THEN 3 - ELSE 2 - END, user - ` - selectUserCountQuery = `SELECT COUNT(*) FROM user` - updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` - updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` - updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` - updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` - updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` - updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` - deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` - deleteUserQuery = `DELETE FROM user WHERE user = ?` - - upsertUserAccessQuery = ` - INSERT INTO user_access (user_id, topic, read, write, owner_user_id) - VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?)))) - ON CONFLICT (user_id, topic) - DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id - ` - selectUserAllAccessQuery = ` - SELECT user_id, topic, read, write - FROM user_access - ORDER BY write DESC, read DESC, topic - ` - selectUserAccessQuery = ` - SELECT topic, read, write - FROM user_access - WHERE user_id = (SELECT id FROM user WHERE user = ?) - ORDER BY write DESC, read DESC, topic - ` - selectUserReservationsQuery = ` - SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write - FROM user_access a_user - LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?) - WHERE a_user.user_id = a_user.owner_user_id - AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?) - ORDER BY a_user.topic - ` - selectUserReservationsCountQuery = ` - SELECT COUNT(*) - FROM user_access - WHERE user_id = owner_user_id - AND owner_user_id = (SELECT id FROM user WHERE user = ?) - ` - selectUserReservationsOwnerQuery = ` - SELECT owner_user_id - FROM user_access - WHERE topic = ? - AND user_id = owner_user_id - ` - selectUserHasReservationQuery = ` - SELECT COUNT(*) - FROM user_access - WHERE user_id = owner_user_id - AND owner_user_id = (SELECT id FROM user WHERE user = ?) - AND topic = ? - ` - selectOtherAccessCountQuery = ` - SELECT COUNT(*) - FROM user_access - WHERE (topic = ? OR ? LIKE topic) - AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?)) - ` - deleteAllAccessQuery = `DELETE FROM user_access` - deleteUserAccessQuery = ` - DELETE FROM user_access - WHERE user_id = (SELECT id FROM user WHERE user = ?) - OR owner_user_id = (SELECT id FROM user WHERE user = ?) - ` - deleteTopicAccessQuery = ` - DELETE FROM user_access - WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?)) - AND topic = ? - ` - - selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?` - selectTokensQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ?` - selectTokenQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ? AND token = ?` - insertTokenQuery = `INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires) VALUES (?, ?, ?, ?, ?, ?)` - updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` - updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` - updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` - deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?` - deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?` - deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` - deleteExcessTokensQuery = ` - DELETE FROM user_token - WHERE (user_id, token) NOT IN ( - SELECT user_id, token - FROM user_token - WHERE user_id = ? - ORDER BY expires DESC - LIMIT ? - ) - ` - - selectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?` - insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` - deletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` - - insertTierQuery = ` - INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ` - updateTierQuery = ` - UPDATE tier - SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? - WHERE code = ? - ` - selectTiersQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id - FROM tier - ` - selectTierByCodeQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id - FROM tier - WHERE code = ? - ` - selectTierByPriceIDQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id - FROM tier - WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?) - ` - updateUserTierQuery = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?` - deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?` - deleteTierQuery = `DELETE FROM tier WHERE code = ?` - - updateBillingQuery = ` - UPDATE user - SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_interval = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ? - WHERE user = ? - ` -) - -// Schema management queries -const ( - currentSchemaVersion = 4 - insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` - updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` - selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` - - // 1 -> 2 (complex migration!) - migrate1To2CreateTablesQueries = ` - ALTER TABLE user RENAME TO user_old; + createTablesQueriesNoTx = ` CREATE TABLE IF NOT EXISTS tier ( id TEXT PRIMARY KEY, code TEXT NOT NULL, @@ -381,9 +110,186 @@ const ( version INT NOT NULL ); INSERT INTO user (id, user, pass, role, sync_topic, created) - VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH()) + VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH()) ON CONFLICT (id) DO NOTHING; ` + createTablesQueries = `BEGIN; ` + createTablesQueriesNoTx + ` COMMIT;` + builtinStartupQueries = ` + PRAGMA foreign_keys = ON; + ` + + selectUserByIDQuery = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id + FROM user u + LEFT JOIN tier t on t.id = u.tier_id + WHERE u.id = ? + ` + selectUserByNameQuery = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id + FROM user u + LEFT JOIN tier t on t.id = u.tier_id + WHERE user = ? + ` + selectUserByTokenQuery = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id + FROM user u + JOIN user_token tk on u.id = tk.user_id + LEFT JOIN tier t on t.id = u.tier_id + WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) + ` + selectUserByStripeCustomerIDQuery = ` + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id + FROM user u + LEFT JOIN tier t on t.id = u.tier_id + WHERE u.stripe_customer_id = ? + ` + selectTopicPermsQuery = ` + SELECT read, write + FROM user_access a + JOIN user u ON u.id = a.user_id + WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic + ORDER BY u.user DESC + ` + + insertUserQuery = ` + INSERT INTO user (id, user, pass, role, sync_topic, created) + VALUES (?, ?, ?, ?, ?, ?) + ` + selectUsernamesQuery = ` + SELECT user + FROM user + ORDER BY + CASE role + WHEN 'admin' THEN 1 + WHEN 'anonymous' THEN 3 + ELSE 2 + END, user + ` + updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` + updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` + updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` + updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?` + updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0` + updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` + deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` + deleteUserQuery = `DELETE FROM user WHERE user = ?` + + upsertUserAccessQuery = ` + INSERT INTO user_access (user_id, topic, read, write, owner_user_id) + VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?)))) + ON CONFLICT (user_id, topic) + DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id + ` + selectUserAccessQuery = ` + SELECT topic, read, write + FROM user_access + WHERE user_id = (SELECT id FROM user WHERE user = ?) + ORDER BY write DESC, read DESC, topic + ` + selectUserReservationsQuery = ` + SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write + FROM user_access a_user + LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?) + WHERE a_user.user_id = a_user.owner_user_id + AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?) + ORDER BY a_user.topic + ` + selectUserReservationsCountQuery = ` + SELECT COUNT(*) + FROM user_access + WHERE user_id = owner_user_id AND owner_user_id = (SELECT id FROM user WHERE user = ?) + ` + selectUserHasReservationQuery = ` + SELECT COUNT(*) + FROM user_access + WHERE user_id = owner_user_id + AND owner_user_id = (SELECT id FROM user WHERE user = ?) + AND topic = ? + ` + selectOtherAccessCountQuery = ` + SELECT COUNT(*) + FROM user_access + WHERE (topic = ? OR ? LIKE topic) + AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?)) + ` + deleteAllAccessQuery = `DELETE FROM user_access` + deleteUserAccessQuery = ` + DELETE FROM user_access + WHERE user_id = (SELECT id FROM user WHERE user = ?) + OR owner_user_id = (SELECT id FROM user WHERE user = ?) + ` + deleteTopicAccessQuery = ` + DELETE FROM user_access + WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?)) + AND topic = ? + ` + + selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?` + selectTokensQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ?` + selectTokenQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ? AND token = ?` + insertTokenQuery = `INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires) VALUES (?, ?, ?, ?, ?, ?)` + updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` + updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` + updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?` + deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?` + deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?` + deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` + deleteExcessTokensQuery = ` + DELETE FROM user_token + WHERE (user_id, token) NOT IN ( + SELECT user_id, token + FROM user_token + WHERE user_id = ? + ORDER BY expires DESC + LIMIT ? + ) + ` + + insertTierQuery = ` + INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + updateTierQuery = ` + UPDATE tier + SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_price_id = ? + WHERE code = ? + ` + selectTiersQuery = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id + FROM tier + ` + selectTierByCodeQuery = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id + FROM tier + WHERE code = ? + ` + selectTierByPriceIDQuery = ` + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id + FROM tier + WHERE stripe_price_id = ? + ` + updateUserTierQuery = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?` + deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?` + deleteTierQuery = `DELETE FROM tier WHERE code = ?` + + updateBillingQuery = ` + UPDATE user + SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ? + WHERE user = ? + ` +) + +// Schema management queries +const ( + currentSchemaVersion = 2 + insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` + updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` + selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` + + // 1 -> 2 (complex migration!) + migrate1To2RenameUserTableQueryNoTx = ` + ALTER TABLE user RENAME TO user_old; + ` migrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old` migrate1To2InsertUserNoTx = ` INSERT INTO user (id, user, pass, role, sync_topic, created) @@ -398,35 +304,11 @@ const ( DROP TABLE access; DROP TABLE user_old; ` - - // 2 -> 3 - migrate2To3UpdateQueries = ` - ALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT; - ALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id; - ALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT; - DROP INDEX IF EXISTS idx_tier_price_id; - CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); - CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); - ` - - // 3 -> 4 - migrate3To4UpdateQueries = ` - ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0); - ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0); - CREATE TABLE IF NOT EXISTS user_phone ( - user_id TEXT NOT NULL, - phone_number TEXT NOT NULL, - PRIMARY KEY (user_id, phone_number), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - ` ) var ( migrations = map[int]func(db *sql.DB) error{ 1: migrateFrom1, - 2: migrateFrom2, - 3: migrateFrom3, } ) @@ -648,56 +530,6 @@ func (a *Manager) RemoveExpiredTokens() error { return nil } -// PhoneNumbers returns all phone numbers for the user with the given user ID -func (a *Manager) PhoneNumbers(userID string) ([]string, error) { - rows, err := a.db.Query(selectPhoneNumbersQuery, userID) - if err != nil { - return nil, err - } - defer rows.Close() - phoneNumbers := make([]string, 0) - for { - phoneNumber, err := a.readPhoneNumber(rows) - if err == ErrPhoneNumberNotFound { - break - } else if err != nil { - return nil, err - } - phoneNumbers = append(phoneNumbers, phoneNumber) - } - return phoneNumbers, nil -} - -func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) { - var phoneNumber string - if !rows.Next() { - return "", ErrPhoneNumberNotFound - } - if err := rows.Scan(&phoneNumber); err != nil { - return "", err - } else if err := rows.Err(); err != nil { - return "", err - } - return phoneNumber, nil -} - -// AddPhoneNumber adds a phone number to the user with the given user ID -func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { - if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil { - if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { - return ErrPhoneNumberExists - } - return err - } - return nil -} - -// RemovePhoneNumber deletes a phone number from the user with the given user ID -func (a *Manager) RemovePhoneNumber(userID string, phoneNumber string) error { - _, err := a.db.Exec(deletePhoneNumberQuery, userID, phoneNumber) - return err -} - // RemoveDeletedUsers deletes all users that have been marked deleted for func (a *Manager) RemoveDeletedUsers() error { if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil { @@ -780,10 +612,9 @@ func (a *Manager) writeUserStatsQueue() error { "user_id": userID, "messages_count": update.Messages, "emails_count": update.Emails, - "calls_count": update.Calls, }). Trace("Updating stats for user %s", userID) - if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.Calls, userID); err != nil { + if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, userID); err != nil { return err } } @@ -865,9 +696,6 @@ func (a *Manager) AddUser(username, password string, role Role) error { userID := util.RandomStringPrefix(userIDPrefix, userIDLength) syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix() if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil { - if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { - return ErrUserExists - } return err } return nil @@ -938,23 +766,6 @@ func (a *Manager) Users() ([]*User, error) { return users, nil } -// UsersCount returns the number of users in the databsae -func (a *Manager) UsersCount() (int64, error) { - rows, err := a.db.Query(selectUserCountQuery) - if err != nil { - return 0, err - } - defer rows.Close() - if !rows.Next() { - return 0, errNoRows - } - var count int64 - if err := rows.Scan(&count); err != nil { - return 0, err - } - return count, nil -} - // User returns the user with the given username if it exists, or ErrUserNotFound otherwise. // You may also pass Everyone to retrieve the anonymous user and its Grant list. func (a *Manager) User(username string) (*User, error) { @@ -994,13 +805,13 @@ func (a *Manager) userByToken(token string) (*User, error) { func (a *Manager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var id, username, hash, role, prefs, syncTopic string - var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString - var messages, emails, calls int64 - var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 + var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripePriceID, tierID, tierCode, tierName sql.NullString + var messages, emails int64 + var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 if !rows.Next() { return nil, ErrUserNotFound } - if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripePriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -1015,15 +826,13 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { Stats: &Stats{ Messages: messages, Emails: emails, - Calls: calls, }, Billing: &Billing{ - StripeCustomerID: stripeCustomerID.String, // May be empty - StripeSubscriptionID: stripeSubscriptionID.String, // May be empty - StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty - StripeSubscriptionInterval: stripe.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty - StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero - StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero + StripeCustomerID: stripeCustomerID.String, // May be empty + StripeSubscriptionID: stripeSubscriptionID.String, // May be empty + StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty + StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero + StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero }, Deleted: deleted.Valid, } @@ -1039,46 +848,17 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { MessageLimit: messagesLimit.Int64, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, EmailLimit: emailsLimit.Int64, - CallLimit: callsLimit.Int64, ReservationLimit: reservationsLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, - StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty - StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty + StripePriceID: stripePriceID.String, // May be empty } } return user, nil } -// AllGrants returns all user-specific access control entries, mapped to their respective user IDs -func (a *Manager) AllGrants() (map[string][]Grant, error) { - rows, err := a.db.Query(selectUserAllAccessQuery) - if err != nil { - return nil, err - } - defer rows.Close() - grants := make(map[string][]Grant, 0) - for rows.Next() { - var userID, topic string - var read, write bool - if err := rows.Scan(&userID, &topic, &read, &write); err != nil { - return nil, err - } else if err := rows.Err(); err != nil { - return nil, err - } - if _, ok := grants[userID]; !ok { - grants[userID] = make([]Grant, 0) - } - grants[userID] = append(grants[userID], Grant{ - TopicPattern: fromSQLWildcard(topic), - Allow: NewPermission(read, write), - }) - } - return grants, nil -} - // Grants returns all user-specific access control entries func (a *Manager) Grants(username string) ([]Grant, error) { rows, err := a.db.Query(selectUserAccessQuery, username) @@ -1163,24 +943,6 @@ func (a *Manager) ReservationsCount(username string) (int64, error) { return count, nil } -// ReservationOwner returns user ID of the user that owns this topic, or an -// empty string if it's not owned by anyone -func (a *Manager) ReservationOwner(topic string) (string, error) { - rows, err := a.db.Query(selectUserReservationsOwnerQuery, topic) - if err != nil { - return "", err - } - defer rows.Close() - if !rows.Next() { - return "", nil - } - var ownerUserID string - if err := rows.Scan(&ownerUserID); err != nil { - return "", err - } - return ownerUserID, nil -} - // ChangePassword changes a user's password func (a *Manager) ChangePassword(username, password string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) @@ -1372,7 +1134,7 @@ func (a *Manager) AddTier(tier *Tier) error { if tier.ID == "" { tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength) } - if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { + if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripePriceID)); err != nil { return err } return nil @@ -1380,7 +1142,7 @@ func (a *Manager) AddTier(tier *Tier) error { // UpdateTier updates a tier's properties in the database func (a *Manager) UpdateTier(tier *Tier) error { - if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { + if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripePriceID), tier.Code); err != nil { return err } return nil @@ -1400,7 +1162,7 @@ func (a *Manager) RemoveTier(code string) error { // ChangeBilling updates a user's billing fields, namely the Stripe customer ID, and subscription information func (a *Manager) ChangeBilling(username string, billing *Billing) error { - if _, err := a.db.Exec(updateBillingQuery, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil { + if _, err := a.db.Exec(updateBillingQuery, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil { return err } return nil @@ -1438,7 +1200,7 @@ func (a *Manager) Tier(code string) (*Tier, error) { // TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { - rows, err := a.db.Query(selectTierByPriceIDQuery, priceID, priceID) + rows, err := a.db.Query(selectTierByPriceIDQuery, priceID) if err != nil { return nil, err } @@ -1448,12 +1210,12 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { var id, code, name string - var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString - var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 + var stripePriceID sql.NullString + var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 if !rows.Next() { return nil, ErrTierNotFound } - if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripePriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -1466,14 +1228,12 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { MessageLimit: messagesLimit.Int64, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, EmailLimit: emailsLimit.Int64, - CallLimit: callsLimit.Int64, ReservationLimit: reservationsLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64, - StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty - StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty + StripePriceID: stripePriceID.String, // May be empty }, nil } @@ -1553,7 +1313,10 @@ func migrateFrom1(db *sql.DB) error { } defer tx.Rollback() // Rename user -> user_old, and create new tables - if _, err := tx.Exec(migrate1To2CreateTablesQueries); err != nil { + if _, err := tx.Exec(migrate1To2RenameUserTableQueryNoTx); err != nil { + return err + } + if _, err := tx.Exec(createTablesQueriesNoTx); err != nil { return err } // Insert users from user_old into new user table, with ID and sync_topic @@ -1593,38 +1356,6 @@ func migrateFrom1(db *sql.DB) error { return nil } -func migrateFrom2(db *sql.DB) error { - log.Tag(tag).Info("Migrating user database schema: from 2 to 3") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate2To3UpdateQueries); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 3); err != nil { - return err - } - return tx.Commit() -} - -func migrateFrom3(db *sql.DB) error { - log.Tag(tag).Info("Migrating user database schema: from 3 to 4") - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil { - return err - } - if _, err := tx.Exec(updateSchemaVersion, 4); err != nil { - return err - } - return tx.Commit() -} - func nullString(s string) sql.NullString { if s == "" { return sql.NullString{} diff --git a/user/manager_test.go b/user/manager_test.go index 5e01f49..f809b5a 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -4,7 +4,6 @@ import ( "database/sql" "fmt" "github.com/stretchr/testify/require" - "github.com/stripe/stripe-go/v74" "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/util" "net/netip" @@ -114,8 +113,7 @@ func TestManager_AddUser_And_Query(t *testing.T) { require.Nil(t, a.ChangeBilling("user", &Billing{ StripeCustomerID: "acct_123", StripeSubscriptionID: "sub_123", - StripeSubscriptionStatus: stripe.SubscriptionStatusActive, - StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth, + StripeSubscriptionStatus: "active", StripeSubscriptionPaidUntil: time.Now().Add(time.Hour), StripeSubscriptionCancelAt: time.Unix(0, 0), })) @@ -133,6 +131,29 @@ func TestManager_AddUser_And_Query(t *testing.T) { require.Equal(t, u.ID, u3.ID) } +func TestManager_Authenticate_Timing(t *testing.T) { + a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval) + require.Nil(t, a.AddUser("user", "pass", RoleAdmin)) + + // Timing a correct attempt + start := time.Now().UnixMilli() + _, err := a.Authenticate("user", "pass") + require.Nil(t, err) + require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) + + // Timing an incorrect attempt + start = time.Now().UnixMilli() + _, err = a.Authenticate("user", "INCORRECT") + require.Equal(t, ErrUnauthenticated, err) + require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) + + // Timing a non-existing user attempt + start = time.Now().UnixMilli() + _, err = a.Authenticate("DOES-NOT-EXIST", "hithere") + require.Equal(t, ErrUnauthenticated, err) + require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) +} + func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) { a := newTestManager(t, PermissionDenyAll) @@ -374,7 +395,7 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) { require.Nil(t, a.AddTier(&Tier{ Code: "pro", Name: "ntfy Pro", - StripeMonthlyPriceID: "price123", + StripePriceID: "price123", MessageLimit: 5_000, MessageExpiryDuration: 3 * 24 * time.Hour, EmailLimit: 50, @@ -740,7 +761,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) { AttachmentTotalSizeLimit: 1, AttachmentExpiryDuration: time.Second, AttachmentBandwidthLimit: 1, - StripeMonthlyPriceID: "price_1", + StripePriceID: "price_1", })) require.Nil(t, a.AddTier(&Tier{ Code: "pro", @@ -753,7 +774,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) { AttachmentTotalSizeLimit: 123123, AttachmentExpiryDuration: 10800 * time.Second, AttachmentBandwidthLimit: 21474836480, - StripeMonthlyPriceID: "price_2", + StripePriceID: "price_2", })) require.Nil(t, a.AddUser("phil", "phil", RoleUser)) require.Nil(t, a.ChangeTier("phil", "pro")) @@ -779,7 +800,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) { require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit) require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration) require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit) - require.Equal(t, "price_2", ti.StripeMonthlyPriceID) + require.Equal(t, "price_2", ti.StripePriceID) // Update tier ti.EmailLimit = 999999 @@ -801,7 +822,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) { require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit) require.Equal(t, time.Second, ti.AttachmentExpiryDuration) require.Equal(t, int64(1), ti.AttachmentBandwidthLimit) - require.Equal(t, "price_1", ti.StripeMonthlyPriceID) + require.Equal(t, "price_1", ti.StripePriceID) ti = tiers[1] require.Equal(t, "pro", ti.Code) @@ -814,7 +835,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) { require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit) require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration) require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit) - require.Equal(t, "price_2", ti.StripeMonthlyPriceID) + require.Equal(t, "price_2", ti.StripePriceID) ti, err = a.TierByStripePrice("price_1") require.Nil(t, err) @@ -828,7 +849,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) { require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit) require.Equal(t, time.Second, ti.AttachmentExpiryDuration) require.Equal(t, int64(1), ti.AttachmentBandwidthLimit) - require.Equal(t, "price_1", ti.StripeMonthlyPriceID) + require.Equal(t, "price_1", ti.StripePriceID) // Cannot remove tier, since user has this tier require.Error(t, a.RemoveTier("pro")) @@ -893,44 +914,6 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) { require.Nil(t, a.ResetTier("phil")) } -func TestUser_PhoneNumberAddListRemove(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - - require.Nil(t, a.AddUser("phil", "phil", RoleUser)) - phil, err := a.User("phil") - require.Nil(t, err) - require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) - - phoneNumbers, err := a.PhoneNumbers(phil.ID) - require.Nil(t, err) - require.Equal(t, 1, len(phoneNumbers)) - require.Equal(t, "+1234567890", phoneNumbers[0]) - - require.Nil(t, a.RemovePhoneNumber(phil.ID, "+1234567890")) - phoneNumbers, err = a.PhoneNumbers(phil.ID) - require.Nil(t, err) - require.Equal(t, 0, len(phoneNumbers)) - - // Paranoia check: We do NOT want to keep phone numbers in there - rows, err := a.db.Query(`SELECT * FROM user_phone`) - require.Nil(t, err) - require.False(t, rows.Next()) - require.Nil(t, rows.Close()) -} - -func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) { - a := newTestManager(t, PermissionDenyAll) - - require.Nil(t, a.AddUser("phil", "phil", RoleUser)) - require.Nil(t, a.AddUser("ben", "ben", RoleUser)) - phil, err := a.User("phil") - require.Nil(t, err) - ben, err := a.User("ben") - require.Nil(t, err) - require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) - require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890")) -} - func TestSqliteCache_Migration_From1(t *testing.T) { filename := filepath.Join(t.TempDir(), "user.db") db, err := sql.Open("sqlite3", filename) diff --git a/user/types.go b/user/types.go index 1189578..0363c97 100644 --- a/user/types.go +++ b/user/types.go @@ -86,23 +86,20 @@ type Tier struct { MessageLimit int64 // Daily message limit MessageExpiryDuration time.Duration // Cache duration for messages EmailLimit int64 // Daily email limit - CallLimit int64 // Daily phone call limit ReservationLimit int64 // Number of topic reservations allowed by user AttachmentFileSizeLimit int64 // Max file size per file (bytes) AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes) AttachmentExpiryDuration time.Duration // Duration after which attachments will be deleted AttachmentBandwidthLimit int64 // Daily bandwidth limit for the user - StripeMonthlyPriceID string // Monthly price ID for paid tiers (price_...) - StripeYearlyPriceID string // Yearly price ID for paid tiers (price_...) + StripePriceID string // Price ID for paid tiers (price_...) } // Context returns fields for the log func (t *Tier) Context() log.Context { return log.Context{ - "tier_id": t.ID, - "tier_code": t.Code, - "stripe_monthly_price_id": t.StripeMonthlyPriceID, - "stripe_yearly_price_id": t.StripeYearlyPriceID, + "tier_id": t.ID, + "tier_code": t.Code, + "stripe_price_id": t.StripePriceID, } } @@ -132,7 +129,6 @@ type NotificationPrefs struct { type Stats struct { Messages int64 Emails int64 - Calls int64 } // Billing is a struct holding a user's billing information @@ -140,7 +136,6 @@ type Billing struct { StripeCustomerID string StripeSubscriptionID string StripeSubscriptionStatus stripe.SubscriptionStatus - StripeSubscriptionInterval stripe.PriceRecurringInterval StripeSubscriptionPaidUntil time.Time StripeSubscriptionCancelAt time.Time } @@ -278,10 +273,7 @@ var ( ErrUnauthorized = errors.New("unauthorized") ErrInvalidArgument = errors.New("invalid argument") ErrUserNotFound = errors.New("user not found") - ErrUserExists = errors.New("user already exists") ErrTierNotFound = errors.New("tier not found") ErrTokenNotFound = errors.New("token not found") - ErrPhoneNumberNotFound = errors.New("phone number not found") ErrTooManyReservations = errors.New("new tier has lower reservation limit") - ErrPhoneNumberExists = errors.New("phone number already exists") ) diff --git a/user/types_test.go b/user/types_test.go index 811d33f..22dd6c7 100644 --- a/user/types_test.go +++ b/user/types_test.go @@ -49,15 +49,12 @@ func TestAllowedTier(t *testing.T) { func TestTierContext(t *testing.T) { tier := &Tier{ - ID: "ti_abc", - Code: "pro", - StripeMonthlyPriceID: "price_123", - StripeYearlyPriceID: "price_456", + ID: "ti_abc", + Code: "pro", + StripePriceID: "price_123", } context := tier.Context() require.Equal(t, "ti_abc", context["tier_id"]) require.Equal(t, "pro", context["tier_code"]) - require.Equal(t, "price_123", context["stripe_monthly_price_id"]) - require.Equal(t, "price_456", context["stripe_yearly_price_id"]) - + require.Equal(t, "price_123", context["stripe_price_id"]) } diff --git a/util/time.go b/util/time.go index 14aa393..04d78f8 100644 --- a/util/time.go +++ b/util/time.go @@ -14,15 +14,6 @@ var ( durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`) ) -const ( - timestampFormat = "2006-01-02T15:04:05.999Z07:00" // Like RFC3339, but with milliseconds -) - -// FormatTime formats a time.Time in a RFC339-like format that includes milliseconds -func FormatTime(t time.Time) string { - return t.Format(timestampFormat) -} - // NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence // of that time from the current time (in UTC). func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time { @@ -54,9 +45,15 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) { return time.Time{}, errUnparsableTime } -// ParseDuration is like time.ParseDuration, except that it also understands days (d), which -// translates to 24 hours, e.g. "2d" or "20h". -func ParseDuration(s string) (time.Duration, error) { +func parseFromDuration(s string, now time.Time) (time.Time, error) { + d, err := parseDuration(s) + if err == nil { + return now.Add(d), nil + } + return time.Time{}, errUnparsableTime +} + +func parseDuration(s string) (time.Duration, error) { d, err := time.ParseDuration(s) if err == nil { return d, nil @@ -83,14 +80,6 @@ func ParseDuration(s string) (time.Duration, error) { return 0, errUnparsableTime } -func parseFromDuration(s string, now time.Time) (time.Time, error) { - d, err := ParseDuration(s) - if err == nil { - return now.Add(d), nil - } - return time.Time{}, errUnparsableTime -} - func parseUnixTime(s string, now time.Time) (time.Time, error) { t, err := strconv.Atoi(s) if err != nil { diff --git a/util/time_test.go b/util/time_test.go index 9cc343f..557919b 100644 --- a/util/time_test.go +++ b/util/time_test.go @@ -78,17 +78,3 @@ func TestParseFutureTime_UnixTime(t *testing.T) { require.Nil(t, err) require.Equal(t, time.Date(2021, 12, 11, 0, 51, 51, 0, time.UTC), d) } - -func TestParseDuration(t *testing.T) { - d, err := ParseDuration("2d") - require.Nil(t, err) - require.Equal(t, 48*time.Hour, d) - - d, err = ParseDuration("2h") - require.Nil(t, err) - require.Equal(t, 2*time.Hour, d) - - d, err = ParseDuration("0") - require.Nil(t, err) - require.Equal(t, time.Duration(0), d) -} diff --git a/util/util.go b/util/util.go index 84177d9..33fa34e 100644 --- a/util/util.go +++ b/util/util.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "golang.org/x/time/rate" "io" "math/rand" "net/netip" @@ -16,8 +17,6 @@ import ( "sync" "time" - "golang.org/x/time/rate" - "github.com/gabriel-vasile/mimetype" "golang.org/x/term" ) @@ -68,12 +67,15 @@ func ContainsIP(haystack []netip.Prefix, needle netip.Addr) bool { // ContainsAll returns true if all needles are contained in haystack func ContainsAll[T comparable](haystack []T, needles []T) bool { - for _, needle := range needles { - if !Contains(haystack, needle) { - return false + matches := 0 + for _, s := range haystack { + for _, needle := range needles { + if s == needle { + matches++ + } } } - return true + return matches == len(needles) } // SplitNoEmpty splits a string using strings.Split, but filters out empty strings diff --git a/util/util_test.go b/util/util_test.go index 49a2412..5717c5c 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -2,6 +2,7 @@ package util import ( "errors" + "golang.org/x/time/rate" "io" "net/netip" "os" @@ -10,8 +11,6 @@ import ( "testing" "time" - "golang.org/x/time/rate" - "github.com/stretchr/testify/require" ) @@ -50,11 +49,6 @@ func TestContains(t *testing.T) { require.False(t, Contains(s, 3)) } -func TestContainsAll(t *testing.T) { - require.True(t, ContainsAll([]int{1, 2, 3}, []int{2, 3})) - require.False(t, ContainsAll([]int{1, 1}, []int{1, 2})) -} - func TestContainsIP(t *testing.T) { require.True(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix("fd00::/8"), netip.MustParsePrefix("1.1.0.0/16")}, netip.MustParseAddr("1.1.1.1"))) require.True(t, ContainsIP([]netip.Prefix{netip.MustParsePrefix("fd00::/8"), netip.MustParsePrefix("1.1.0.0/16")}, netip.MustParseAddr("fd12:1234:5678::9876"))) diff --git a/web/.eslintignore b/web/.eslintignore deleted file mode 100644 index 29c9584..0000000 --- a/web/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -src/app/emojis.js \ No newline at end of file diff --git a/web/.eslintrc b/web/.eslintrc deleted file mode 100644 index adf6613..0000000 --- a/web/.eslintrc +++ /dev/null @@ -1,37 +0,0 @@ -{ - "extends": ["airbnb", "prettier"], - "env": { - "browser": true - }, - "globals": { - "config": "readonly" - }, - "parserOptions": { - "ecmaVersion": 2023 - }, - "rules": { - "no-console": "off", - "class-methods-use-this": "off", - "func-style": ["error", "expression"], - "no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"], - "no-await-in-loop": "error", - "import/no-cycle": "warn", - "react/prop-types": "off", - "react/destructuring-assignment": "off", - "react/jsx-no-useless-fragment": "off", - "react/jsx-props-no-spreading": "off", - "react/jsx-no-duplicate-props": [ - "error", - { - "ignoreCase": false // For 's [iI]nputProps - } - ], - "react/function-component-definition": [ - "error", - { - "namedComponents": "arrow-function", - "unnamedComponents": "arrow-function" - } - ] - } -} diff --git a/web/.prettierignore b/web/.prettierignore deleted file mode 100644 index 802cdb8..0000000 --- a/web/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -build/ -dist/ -public/static/langs/ -src/app/emojis.js diff --git a/web/index.html b/web/index.html deleted file mode 100644 index c146e64..0000000 --- a/web/index.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - ntfy web - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - diff --git a/web/package-lock.json b/web/package-lock.json index b5754d9..7680222 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,8 +8,8 @@ "name": "ntfy", "version": "1.0.0", "dependencies": { - "@emotion/react": "^11.11.0", - "@emotion/styled": "^11.11.0", + "@emotion/react": "^11.8.2", + "@emotion/styled": "^11.8.1", "@mui/icons-material": "^5.4.2", "@mui/material": "latest", "dexie": "^3.2.1", @@ -24,29 +24,17 @@ "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-router-dom": "^6.2.2", + "react-scripts": "^5.0.0", "stacktrace-gps": "^3.0.4", "stacktrace-js": "^2.0.2" - }, - "devDependencies": { - "@vitejs/plugin-react": "^4.0.0", - "eslint": "^8.41.0", - "eslint-config-airbnb": "^19.0.4", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.32.2", - "eslint-plugin-react-hooks": "^4.6.0", - "prettier": "^2.8.8", - "vite": "^4.3.8" } }, "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", - "dev": true, + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", + "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/gen-mapping": "^0.1.0", "@jridgewell/trace-mapping": "^0.3.9" }, "engines": { @@ -54,9 +42,9 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.21.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz", - "integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", + "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", "dependencies": { "@babel/highlight": "^7.18.6" }, @@ -65,30 +53,28 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.21.9", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.9.tgz", - "integrity": "sha512-FUGed8kfhyWvbYug/Un/VPJD41rDIgoVVcR+FuzhzOYyRz5uED+Gd3SLZml0Uw2l2aHFb7ZgdW5mGA3G2cCCnQ==", - "dev": true, + "version": "7.20.14", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.14.tgz", + "integrity": "sha512-0YpKHD6ImkWMEINCyDAD0HLLUH/lPCefG8ld9it8DJB2wnApraKuhgYTvTY1z7UFIfBTGy5LwncZ+5HWWGbhFw==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.21.8", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.8.tgz", - "integrity": "sha512-YeM22Sondbo523Sz0+CirSPnbj9bG3P0CdHcBZdqUuaeOaYEFbOLoGU7lebvGP6P5J/WE9wOn7u7C4J9HvS1xQ==", - "dev": true, + "version": "7.20.12", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz", + "integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.21.4", - "@babel/generator": "^7.21.5", - "@babel/helper-compilation-targets": "^7.21.5", - "@babel/helper-module-transforms": "^7.21.5", - "@babel/helpers": "^7.21.5", - "@babel/parser": "^7.21.8", + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.20.7", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helpers": "^7.20.7", + "@babel/parser": "^7.20.7", "@babel/template": "^7.20.7", - "@babel/traverse": "^7.21.5", - "@babel/types": "^7.21.5", + "@babel/traverse": "^7.20.12", + "@babel/types": "^7.20.7", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -103,29 +89,87 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/generator": { - "version": "7.21.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.9.tgz", - "integrity": "sha512-F3fZga2uv09wFdEjEQIJxXALXfz0+JaOb7SabvVMmjHxeVTuGW8wgE8Vp1Hd7O+zMTYtcfEISGRzPkeiaPPsvg==", - "dev": true, + "node_modules/@babel/eslint-parser": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.19.1.tgz", + "integrity": "sha512-AqNf2QWt1rtu2/1rLswy6CDP7H9Oh3mMhk177Y67Rg8d7RD9WfOLLv8CGn6tisFvS2htm86yIe1yLF6I1UDaGQ==", "dependencies": { - "@babel/types": "^7.21.5", + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.11.0", + "eslint": "^7.5.0 || ^8.0.0" + } + }, + "node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@babel/generator": { + "version": "7.20.14", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.14.tgz", + "integrity": "sha512-AEmuXHdcD3A52HHXxaTmYlb8q/xMEhoRP67B3T4Oq7lbmSoqroMZzjnGj3+i1io3pdnF8iBYVu4Ilj+c4hBxYg==", + "dependencies": { + "@babel/types": "^7.20.7", "@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-compilation-targets": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.5.tgz", - "integrity": "sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==", - "dev": true, + "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", "dependencies": { - "@babel/compat-data": "^7.21.5", - "@babel/helper-validator-option": "^7.21.0", + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", + "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz", + "integrity": "sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==", + "dependencies": { + "@babel/helper-explode-assignable-expression": "^7.18.6", + "@babel/types": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz", + "integrity": "sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==", + "dependencies": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-validator-option": "^7.18.6", "browserslist": "^4.21.3", "lru-cache": "^5.1.1", "semver": "^6.3.0" @@ -137,23 +181,84 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.20.12", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.20.12.tgz", + "integrity": "sha512-9OunRkbT0JQcednL0UFvbfXpAsUXiGjUk0a7sN8fUXX7Mue79cUSMjHGDRRi/Vz9vYlpIhLV5fMD5dKoMhhsNQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.19.0", + "@babel/helper-member-expression-to-functions": "^7.20.7", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-replace-supers": "^7.20.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/helper-split-export-declaration": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.20.5.tgz", + "integrity": "sha512-m68B1lkg3XDGX5yCvGO0kPx3v9WIYLnzjKfPcQiwntEQa5ZeRkPmo2X/ISJc8qxWGfwUr+kvZAeEzAwLec2r2w==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "regexpu-core": "^5.2.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz", + "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==", + "dependencies": { + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0-0" + } + }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.21.5.tgz", - "integrity": "sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==", - "dev": true, + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", + "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-explode-assignable-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz", + "integrity": "sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==", + "dependencies": { + "@babel/types": "^7.18.6" + }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz", - "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==", - "dev": true, + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz", + "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==", "dependencies": { - "@babel/template": "^7.20.7", - "@babel/types": "^7.21.0" + "@babel/template": "^7.18.10", + "@babel/types": "^7.19.0" }, "engines": { "node": ">=6.9.0" @@ -163,7 +268,6 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", - "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -171,52 +275,115 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.21.4", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz", - "integrity": "sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==", + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.20.7.tgz", + "integrity": "sha512-9J0CxJLq315fEdi4s7xK5TQaNYjZw+nDVpVqr1axNGKzdrdwYBD5b4uKv3n75aABG0rCCTK8Im8Ww7eYfMrZgw==", "dependencies": { - "@babel/types": "^7.21.4" + "@babel/types": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "dependencies": { + "@babel/types": "^7.18.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.21.5.tgz", - "integrity": "sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==", - "dev": true, + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz", + "integrity": "sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==", "dependencies": { - "@babel/helper-environment-visitor": "^7.21.5", - "@babel/helper-module-imports": "^7.21.4", - "@babel/helper-simple-access": "^7.21.5", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-simple-access": "^7.20.2", "@babel/helper-split-export-declaration": "^7.18.6", "@babel/helper-validator-identifier": "^7.19.1", "@babel/template": "^7.20.7", - "@babel/traverse": "^7.21.5", - "@babel/types": "^7.21.5" + "@babel/traverse": "^7.20.10", + "@babel/types": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz", + "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==", + "dependencies": { + "@babel/types": "^7.18.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.21.5.tgz", - "integrity": "sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==", - "dev": true, + "version": "7.20.2", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz", + "integrity": "sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz", + "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-wrap-function": "^7.18.9", + "@babel/types": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.20.7.tgz", + "integrity": "sha512-vujDMtB6LVfNW13jhlCrp48QNslK6JXi7lQG736HVbHz/mbf4Dc7tIRh1Xf5C0rF7BP8iiSxGMCmY6Ci1ven3A==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-member-expression-to-functions": "^7.20.7", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.20.7", + "@babel/types": "^7.20.7" + }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-simple-access": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.21.5.tgz", - "integrity": "sha512-ENPDAMC1wAjR0uaCUwliBdiSl1KBJAVnMTzXqi64c2MG8MPR6ii4qf7bSXDqSFbr4W6W028/rf5ivoHop5/mkg==", - "dev": true, + "version": "7.20.2", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz", + "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==", "dependencies": { - "@babel/types": "^7.21.5" + "@babel/types": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.20.0.tgz", + "integrity": "sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg==", + "dependencies": { + "@babel/types": "^7.20.0" }, "engines": { "node": ">=6.9.0" @@ -226,7 +393,6 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", - "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -235,9 +401,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz", - "integrity": "sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==", + "version": "7.19.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz", + "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==", "engines": { "node": ">=6.9.0" } @@ -251,23 +417,35 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz", - "integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==", - "dev": true, + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz", + "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.20.5.tgz", + "integrity": "sha512-bYMxIWK5mh+TgXGVqAtnu5Yn1un+v8DDZtqyzKRLUzrh70Eal2O3aZ7aPYiMADO4uKlkzOiRiZ6GX5q3qxvW9Q==", + "dependencies": { + "@babel/helper-function-name": "^7.19.0", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.20.5", + "@babel/types": "^7.20.5" + }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.21.5.tgz", - "integrity": "sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==", - "dev": true, + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.13.tgz", + "integrity": "sha512-nzJ0DWCL3gB5RCXbUO3KIMMsBY2Eqbx8mBpKGE/02PgyRQFcPQLbkQ1vyy596mZLaP+dAfD+R4ckASzNVmW3jg==", "dependencies": { "@babel/template": "^7.20.7", - "@babel/traverse": "^7.21.5", - "@babel/types": "^7.21.5" + "@babel/traverse": "^7.20.13", + "@babel/types": "^7.20.7" }, "engines": { "node": ">=6.9.0" @@ -287,10 +465,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.21.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.9.tgz", - "integrity": "sha512-q5PNg/Bi1OpGgx5jYlvWZwAorZepEudDMCLtj967aeS7WMont7dUZI46M2XwcIQqvUlMxWfdLFu4S/qSxeUu5g==", - "dev": true, + "version": "7.20.15", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.15.tgz", + "integrity": "sha512-DI4a1oZuf8wC+oAJA9RW6ga3Zbe8RZFt7kD9i4qAspz3I/yHet1VvC3DiSy/fsUvv5pvJuNPh0LPOdCcqinDPg==", "bin": { "parser": "bin/babel-parser.js" }, @@ -298,13 +475,45 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.21.0.tgz", - "integrity": "sha512-f/Eq+79JEu+KUANFks9UZCcvydOOGMgF7jBrcwjHa5jTZD8JivnhCJYvmlhR/WTXBWonDExPoW0eO/CR4QJirA==", - "dev": true, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz", + "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==", "dependencies": { - "@babel/helper-plugin-utils": "^7.20.2" + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.20.7.tgz", + "integrity": "sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-proposal-optional-chaining": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-proposal-async-generator-functions": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.7.tgz", + "integrity": "sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-remap-async-to-generator": "^7.18.9", + "@babel/plugin-syntax-async-generators": "^7.8.4" }, "engines": { "node": ">=6.9.0" @@ -313,11 +522,292 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.19.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.19.6.tgz", - "integrity": "sha512-RpAi004QyMNisst/pvSanoRdJ4q+jMCWyk9zdw/CyLB9j8RXEahodR6l2GyttDRyEVWZtbN+TpLiHJ3t34LbsQ==", - "dev": true, + "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==", + "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-class-static-block": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.20.7.tgz", + "integrity": "sha512-AveGOoi9DAjUYYuUAG//Ig69GlazLnoyzMw68VCDux+c1tsnnH/OkYcpz/5xzMkEFC6UxjR5Gw1c+iY2wOGVeQ==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.20.7", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.20.13.tgz", + "integrity": "sha512-7T6BKHa9Cpd7lCueHBBzP0nkXNina+h5giOZw+a8ZpMfPFY19VjJAjIxyFHuWkhCWgL6QMqRiY/wB1fLXzm6Mw==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.20.12", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-replace-supers": "^7.20.7", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/plugin-syntax-decorators": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-dynamic-import": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz", + "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-namespace-from": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz", + "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-json-strings": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz", + "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-json-strings": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.20.7.tgz", + "integrity": "sha512-y7C7cZgpMIjWlKE5T7eJwp+tnRYM89HmRvWM5EQuB5BoHEONjmQ8lSNmBUwOyy/GFRsohJED51YBF79hE1djug==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", + "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-numeric-separator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", + "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "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==", + "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-proposal-optional-catch-binding": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz", + "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-chaining": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.20.7.tgz", + "integrity": "sha512-T+A7b1kfjtRM51ssoOfS1+wbyCVqorfyZhT99TvxxLMirPShD8CzKMRepMlCBGM5RpHMbn8s+5MMHnPstJH6mQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "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-private-property-in-object": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.20.5.tgz", + "integrity": "sha512-Vq7b9dUA12ByzB4EjQTPo25sFhY+08pQDBSZRtUAkj7lb7jahaHR5igera16QZ+3my1nYR4dKsNdYj5IjPHilQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.20.5", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-unicode-property-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz", + "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.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==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.19.0.tgz", + "integrity": "sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ==", "dependencies": { "@babel/helper-plugin-utils": "^7.19.0" }, @@ -328,10 +818,947 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.18.6.tgz", + "integrity": "sha512-LUbR+KNTBWCUAqRG9ex5Gnzu2IOkt8jRJbHHXFT9q+L9zm7M/QQbEqXyw1n1pohYvOyWC8CjeyjrSaIwiYjK7A==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.20.0.tgz", + "integrity": "sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz", + "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "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==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz", + "integrity": "sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.20.7.tgz", + "integrity": "sha512-3poA5E7dzDomxj9WXWwuD6A5F3kc7VXwIJO+E+J8qtDtS+pXPAhrgEyh+9GBwBgPq1Z+bB+/JD60lp5jsN7JPQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.20.7.tgz", + "integrity": "sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-remap-async-to-generator": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz", + "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.20.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.20.15.tgz", + "integrity": "sha512-Vv4DMZ6MiNOhu/LdaZsT/bsLRxgL94d269Mv4R/9sp6+Mp++X/JqypZYypJXLlM4mlL352/Egzbzr98iABH1CA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.20.7.tgz", + "integrity": "sha512-LWYbsiXTPKl+oBlXUGlwNlJZetXD5Am+CyBdqhPsDVjM9Jc8jwBJFrKhHf900Kfk2eZG1y9MAG3UNajol7A4VQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.19.0", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-replace-supers": "^7.20.7", + "@babel/helper-split-export-declaration": "^7.18.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.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.20.7.tgz", + "integrity": "sha512-Lz7MvBK6DTjElHAmfu6bfANzKcxpyNPeYBGEafyA6E5HtRpjpZwU+u7Qrgz/2OR0z+5TvKYbPdphfSaAcZBrYQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/template": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.20.7.tgz", + "integrity": "sha512-Xwg403sRrZb81IVB79ZPqNQME23yhugYVqgTxAhT99h485F4f+GMELFhhOsscDUB7HCswepKeCKLn/GZvUKoBA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz", + "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==", + "dependencies": { + "@babel/helper-create-regexp-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-transform-duplicate-keys": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz", + "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz", + "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==", + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^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-transform-flow-strip-types": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.19.0.tgz", + "integrity": "sha512-sgeMlNaQVbCSpgLSKP4ZZKfsJVnFnNQlUSk6gPYzR/q7tzCgQF2t8RBKAP6cKJeZdveei7Q7Jm527xepI8lNLg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/plugin-syntax-flow": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.18.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz", + "integrity": "sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz", + "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==", + "dependencies": { + "@babel/helper-compilation-targets": "^7.18.9", + "@babel/helper-function-name": "^7.18.9", + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz", + "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz", + "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.20.11.tgz", + "integrity": "sha512-NuzCt5IIYOW0O30UvqktzHYR2ud5bOWbY0yaxWZ6G+aFzOMJvrs5YHNikrbdaT15+KNO31nPOy5Fim3ku6Zb5g==", + "dependencies": { + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.20.11.tgz", + "integrity": "sha512-S8e1f7WQ7cimJQ51JkAaDrEtohVEitXjgCGAS2N8S31Y42E+kWwfSz83LYz57QdBm7q9diARVqanIaH2oVgQnw==", + "dependencies": { + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-simple-access": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.20.11.tgz", + "integrity": "sha512-vVu5g9BPQKSFEmvt2TA4Da5N+QVS66EX21d8uoOihC+OCpUoGvzVsXeqFdtAEfVa5BILAeFt+U7yVmLbQnAJmw==", + "dependencies": { + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-validator-identifier": "^7.19.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz", + "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==", + "dependencies": { + "@babel/helper-module-transforms": "^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-transform-named-capturing-groups-regex": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.20.5.tgz", + "integrity": "sha512-mOW4tTzi5iTLnw+78iEq3gr8Aoq4WNRGpmSlrogqaiCBoR1HFhpU4JkpQFOHfeYx3ReVIFWOQJS4aZBRvuZ6mA==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.20.5", + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz", + "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz", + "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-replace-supers": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.20.7.tgz", + "integrity": "sha512-WiWBIkeHKVOSYPO0pWkxGPfKeWrCJyD3NJ53+Lrp/QMSZbsVPovrVl2aWZ19D/LTVnaDv5Ap7GJ/B2CTOZdrfA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz", + "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.20.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.20.2.tgz", + "integrity": "sha512-KS/G8YI8uwMGKErLFOHS/ekhqdHhpEloxs43NecQHVgo2QuQSyJhGIY1fL8UGl9wy5ItVwwoUL4YxVqsplGq2g==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz", + "integrity": "sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.20.13.tgz", + "integrity": "sha512-MmTZx/bkUrfJhhYAYt3Urjm+h8DQGrPrnKQ94jLo7NLuOU+T89a7IByhKmrb8SKhrIYIQ0FN0CHMbnFRen4qNw==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz", + "integrity": "sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.6.tgz", + "integrity": "sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^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-transform-regenerator": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.20.5.tgz", + "integrity": "sha512-kW/oO7HPBtntbsahzQ0qSE3tFvkFwnbozz3NWFhLGqH75vLEg+sCGngLlhVkePlCs3Jv0dBBHDzCHxNiFAQKCQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "regenerator-transform": "^0.15.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz", + "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.19.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.19.6.tgz", + "integrity": "sha512-PRH37lz4JU156lYFW1p8OxE5i7d6Sl/zV58ooyr+q1J1lnQPyg5tIiXlIwNVhJaY4W3TmOtdc8jqdXQcB1v5Yw==", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.19.0", + "babel-plugin-polyfill-corejs2": "^0.3.3", + "babel-plugin-polyfill-corejs3": "^0.6.0", + "babel-plugin-polyfill-regenerator": "^0.4.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz", + "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.20.7.tgz", + "integrity": "sha512-ewBbHQ+1U/VnH1fxltbJqDeWBU1oNLG8Dj11uIv3xVf7nrQu0bPGe5Rf716r7K5Qz+SqtAOVswoVunoiBtGhxw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz", + "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz", + "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz", + "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.20.13.tgz", + "integrity": "sha512-O7I/THxarGcDZxkgWKMUrk7NK1/WbHAg3Xx86gqS6x9MTrNL6AwIluuZ96ms4xeDe6AVx6rjHbWHP7x26EPQBA==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.20.12", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-typescript": "^7.20.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.18.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz", + "integrity": "sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz", + "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==", + "dependencies": { + "@babel/helper-create-regexp-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/preset-env": { + "version": "7.20.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.20.2.tgz", + "integrity": "sha512-1G0efQEWR1EHkKvKHqbG+IN/QdgwfByUpM5V5QroDzGV2t3S/WXNQd693cHiHTlCFMpr9B6FkPFXDA2lQcKoDg==", + "dependencies": { + "@babel/compat-data": "^7.20.1", + "@babel/helper-compilation-targets": "^7.20.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.18.9", + "@babel/plugin-proposal-async-generator-functions": "^7.20.1", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-class-static-block": "^7.18.6", + "@babel/plugin-proposal-dynamic-import": "^7.18.6", + "@babel/plugin-proposal-export-namespace-from": "^7.18.9", + "@babel/plugin-proposal-json-strings": "^7.18.6", + "@babel/plugin-proposal-logical-assignment-operators": "^7.18.9", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", + "@babel/plugin-proposal-numeric-separator": "^7.18.6", + "@babel/plugin-proposal-object-rest-spread": "^7.20.2", + "@babel/plugin-proposal-optional-catch-binding": "^7.18.6", + "@babel/plugin-proposal-optional-chaining": "^7.18.9", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "@babel/plugin-proposal-private-property-in-object": "^7.18.6", + "@babel/plugin-proposal-unicode-property-regex": "^7.18.6", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.20.0", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-transform-arrow-functions": "^7.18.6", + "@babel/plugin-transform-async-to-generator": "^7.18.6", + "@babel/plugin-transform-block-scoped-functions": "^7.18.6", + "@babel/plugin-transform-block-scoping": "^7.20.2", + "@babel/plugin-transform-classes": "^7.20.2", + "@babel/plugin-transform-computed-properties": "^7.18.9", + "@babel/plugin-transform-destructuring": "^7.20.2", + "@babel/plugin-transform-dotall-regex": "^7.18.6", + "@babel/plugin-transform-duplicate-keys": "^7.18.9", + "@babel/plugin-transform-exponentiation-operator": "^7.18.6", + "@babel/plugin-transform-for-of": "^7.18.8", + "@babel/plugin-transform-function-name": "^7.18.9", + "@babel/plugin-transform-literals": "^7.18.9", + "@babel/plugin-transform-member-expression-literals": "^7.18.6", + "@babel/plugin-transform-modules-amd": "^7.19.6", + "@babel/plugin-transform-modules-commonjs": "^7.19.6", + "@babel/plugin-transform-modules-systemjs": "^7.19.6", + "@babel/plugin-transform-modules-umd": "^7.18.6", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.19.1", + "@babel/plugin-transform-new-target": "^7.18.6", + "@babel/plugin-transform-object-super": "^7.18.6", + "@babel/plugin-transform-parameters": "^7.20.1", + "@babel/plugin-transform-property-literals": "^7.18.6", + "@babel/plugin-transform-regenerator": "^7.18.6", + "@babel/plugin-transform-reserved-words": "^7.18.6", + "@babel/plugin-transform-shorthand-properties": "^7.18.6", + "@babel/plugin-transform-spread": "^7.19.0", + "@babel/plugin-transform-sticky-regex": "^7.18.6", + "@babel/plugin-transform-template-literals": "^7.18.9", + "@babel/plugin-transform-typeof-symbol": "^7.18.9", + "@babel/plugin-transform-unicode-escapes": "^7.18.10", + "@babel/plugin-transform-unicode-regex": "^7.18.6", + "@babel/preset-modules": "^0.1.5", + "@babel/types": "^7.20.2", + "babel-plugin-polyfill-corejs2": "^0.3.3", + "babel-plugin-polyfill-corejs3": "^0.6.0", + "babel-plugin-polyfill-regenerator": "^0.4.1", + "core-js-compat": "^3.25.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", + "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz", + "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-transform-react-display-name": "^7.18.6", + "@babel/plugin-transform-react-jsx": "^7.18.6", + "@babel/plugin-transform-react-jsx-development": "^7.18.6", + "@babel/plugin-transform-react-pure-annotations": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz", + "integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-transform-typescript": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" + }, "node_modules/@babel/runtime": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", - "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz", + "integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==", "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -340,33 +1767,31 @@ } }, "node_modules/@babel/template": { - "version": "7.21.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.21.9.tgz", - "integrity": "sha512-MK0X5k8NKOuWRamiEfc3KEJiHMTkGZNUjzMipqCGDDc6ijRl/B7RGSKVGncu4Ro/HdyzzY6cmoXuKI2Gffk7vQ==", - "dev": true, + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", + "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==", "dependencies": { - "@babel/code-frame": "^7.21.4", - "@babel/parser": "^7.21.9", - "@babel/types": "^7.21.5" + "@babel/code-frame": "^7.18.6", + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.5.tgz", - "integrity": "sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==", - "dev": true, + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.13.tgz", + "integrity": "sha512-kMJXfF0T6DIS9E8cgdLCSAL+cuCK+YEZHWiLK0SXpTo8YRj5lpJu3CDNKiIBCne4m9hhTIqUg6SYTAI39tAiVQ==", "dependencies": { - "@babel/code-frame": "^7.21.4", - "@babel/generator": "^7.21.5", - "@babel/helper-environment-visitor": "^7.21.5", - "@babel/helper-function-name": "^7.21.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.20.7", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.19.0", "@babel/helper-hoist-variables": "^7.18.6", "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.21.5", - "@babel/types": "^7.21.5", + "@babel/parser": "^7.20.13", + "@babel/types": "^7.20.7", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -375,11 +1800,11 @@ } }, "node_modules/@babel/types": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.5.tgz", - "integrity": "sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==", + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.7.tgz", + "integrity": "sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==", "dependencies": { - "@babel/helper-string-parser": "^7.21.5", + "@babel/helper-string-parser": "^7.19.4", "@babel/helper-validator-identifier": "^7.19.1", "to-fast-properties": "^2.0.0" }, @@ -387,524 +1812,435 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" + }, + "node_modules/@csstools/normalize.css": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.0.0.tgz", + "integrity": "sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg==" + }, + "node_modules/@csstools/postcss-cascade-layers": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-1.1.1.tgz", + "integrity": "sha512-+KdYrpKC5TgomQr2DlZF4lDEpHcoxnj5IGddYYfBWJAKfj1JtuHUIqMa+E1pJJ+z3kvDViWMqyqPlG4Ja7amQA==", + "dependencies": { + "@csstools/selector-specificity": "^2.0.2", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-color-function": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-1.1.1.tgz", + "integrity": "sha512-Bc0f62WmHdtRDjf5f3e2STwRAl89N2CLb+9iAwzrv4L2hncrbDwnQD9PCq0gtAt7pOI2leIV08HIBUd4jxD8cw==", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-font-format-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-1.0.1.tgz", + "integrity": "sha512-ZgrlzuUAjXIOc2JueK0X5sZDjCtgimVp/O5CEqTcs5ShWBa6smhWYbS0x5cVc/+rycTDbjjzoP0KTDnUneZGOg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-hwb-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-1.0.2.tgz", + "integrity": "sha512-YHdEru4o3Rsbjmu6vHy4UKOXZD+Rn2zmkAmLRfPet6+Jz4Ojw8cbWxe1n42VaXQhD3CQUXXTooIy8OkVbUcL+w==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-ic-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-1.0.1.tgz", + "integrity": "sha512-Ot1rcwRAaRHNKC9tAqoqNZhjdYBzKk1POgWfhN4uCOE47ebGcLRqXjKkApVDpjifL6u2/55ekkpnFcp+s/OZUw==", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-2.0.7.tgz", + "integrity": "sha512-7JPeVVZHd+jxYdULl87lvjgvWldYu+Bc62s9vD/ED6/QTGjy0jy0US/f6BG53sVMTBJ1lzKZFpYmofBN9eaRiA==", + "dependencies": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-nested-calc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-1.0.0.tgz", + "integrity": "sha512-JCsQsw1wjYwv1bJmgjKSoZNvf7R6+wuHDAbi5f/7MbFhl2d/+v+TvBTU4BJH3G1X1H87dHl0mh6TfYogbT/dJQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-normalize-display-values": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-1.0.1.tgz", + "integrity": "sha512-jcOanIbv55OFKQ3sYeFD/T0Ti7AMXc9nM1hZWu8m/2722gOTxFg7xYu4RDLJLeZmPUVQlGzo4jhzvTUq3x4ZUw==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-oklab-function": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-1.1.1.tgz", + "integrity": "sha512-nJpJgsdA3dA9y5pgyb/UfEzE7W5Ka7u0CX0/HIMVBNWzWemdcTH3XwANECU6anWv/ao4vVNLTMxhiPNZsTK6iA==", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-progressive-custom-properties": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-1.3.0.tgz", + "integrity": "sha512-ASA9W1aIy5ygskZYuWams4BzafD12ULvSypmaLJT2jvQ8G0M3I8PRQhC0h7mG0Z3LI05+agZjqSR9+K9yaQQjA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/@csstools/postcss-stepped-value-functions": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-1.0.1.tgz", + "integrity": "sha512-dz0LNoo3ijpTOQqEJLY8nyaapl6umbmDcgj4AD0lgVQ572b2eqA1iGZYTTWhrcrHztWDDRAX2DGYyw2VBjvCvQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-text-decoration-shorthand": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-1.0.0.tgz", + "integrity": "sha512-c1XwKJ2eMIWrzQenN0XbcfzckOLLJiczqy+YvfGmzoVXd7pT9FfObiSEfzs84bpE/VqfpEuAZ9tCRbZkZxxbdw==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-trigonometric-functions": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-1.0.2.tgz", + "integrity": "sha512-woKaLO///4bb+zZC2s80l+7cm07M7268MsyG3M0ActXXEFi6SuhvriQYcb58iiKGbjwwIU7n45iRLEHypB47Og==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-unset-value": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.2.tgz", + "integrity": "sha512-c8J4roPBILnelAsdLr4XOAR/GsTm0GJi4XpcfvoWk3U6KiTCqiFYc63KhRMQQX35jYMp4Ao8Ij9+IZRgMfJp1g==", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.1.1.tgz", + "integrity": "sha512-jwx+WCqszn53YHOfvFMJJRd/B2GqkCBt+1MJSG6o5/s8+ytHMvDZXsJgUEWLk12UnLd7HYKac4BYU5i/Ron1Cw==", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.4", + "postcss-selector-parser": "^6.0.10" + } + }, "node_modules/@emotion/babel-plugin": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", - "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", + "version": "11.10.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.10.5.tgz", + "integrity": "sha512-xE7/hyLHJac7D2Ve9dKroBBZqBT7WuPQmWcq7HSGb84sUuP4mlOWoB8dvVfD9yk5DHkU1m6RW7xSoDtnQHNQeA==", "dependencies": { "@babel/helper-module-imports": "^7.16.7", + "@babel/plugin-syntax-jsx": "^7.17.12", "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/serialize": "^1.1.2", + "@emotion/hash": "^0.9.0", + "@emotion/memoize": "^0.8.0", + "@emotion/serialize": "^1.1.1", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", - "stylis": "4.2.0" + "stylis": "4.1.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "node_modules/@emotion/cache": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", - "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", + "version": "11.10.5", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.10.5.tgz", + "integrity": "sha512-dGYHWyzTdmK+f2+EnIGBpkz1lKc4Zbj2KHd4cX3Wi8/OWr5pKslNjc3yABKH4adRGCvSX4VDC0i04mrrq0aiRA==", "dependencies": { - "@emotion/memoize": "^0.8.1", - "@emotion/sheet": "^1.2.2", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", - "stylis": "4.2.0" + "@emotion/memoize": "^0.8.0", + "@emotion/sheet": "^1.2.1", + "@emotion/utils": "^1.2.0", + "@emotion/weak-memoize": "^0.3.0", + "stylis": "4.1.3" } }, "node_modules/@emotion/hash": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", - "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz", + "integrity": "sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", - "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.0.tgz", + "integrity": "sha512-3aDpDprjM0AwaxGE09bOPkNxHpBd+kA6jty3RnaEXdweX1DF1U3VQpPYb0g1IStAuK7SVQ1cy+bNBBKp4W3Fjg==", "dependencies": { - "@emotion/memoize": "^0.8.1" + "@emotion/memoize": "^0.8.0" } }, "node_modules/@emotion/memoize": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", - "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.0.tgz", + "integrity": "sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA==" }, "node_modules/@emotion/react": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.0.tgz", - "integrity": "sha512-ZSK3ZJsNkwfjT3JpDAWJZlrGD81Z3ytNDsxw1LKq1o+xkmO5pnWfr6gmCC8gHEFf3nSSX/09YrG67jybNPxSUw==", + "version": "11.10.5", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.10.5.tgz", + "integrity": "sha512-TZs6235tCJ/7iF6/rvTaOH4oxQg2gMAcdHemjwLKIjKz4rRuYe1HJ2TQJKnAcRAfOUDdU8XoDadCe1rl72iv8A==", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.2", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1", - "@emotion/weak-memoize": "^0.3.1", + "@emotion/babel-plugin": "^11.10.5", + "@emotion/cache": "^11.10.5", + "@emotion/serialize": "^1.1.1", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", + "@emotion/utils": "^1.2.0", + "@emotion/weak-memoize": "^0.3.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { + "@babel/core": "^7.0.0", "react": ">=16.8.0" }, "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, "@types/react": { "optional": true } } }, "node_modules/@emotion/serialize": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz", - "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-Zl/0LFggN7+L1liljxXdsVSVlg6E/Z/olVWpfxUTxOAmi8NU7YoeWeLfi1RmnB2TATHoaWwIBRoL+FvAJiTUQA==", "dependencies": { - "@emotion/hash": "^0.9.1", - "@emotion/memoize": "^0.8.1", - "@emotion/unitless": "^0.8.1", - "@emotion/utils": "^1.2.1", + "@emotion/hash": "^0.9.0", + "@emotion/memoize": "^0.8.0", + "@emotion/unitless": "^0.8.0", + "@emotion/utils": "^1.2.0", "csstype": "^3.0.2" } }, "node_modules/@emotion/sheet": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", - "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.1.tgz", + "integrity": "sha512-zxRBwl93sHMsOj4zs+OslQKg/uhF38MB+OMKoCrVuS0nyTkqnau+BM3WGEoOptg9Oz45T/aIGs1qbVAsEFo3nA==" }, "node_modules/@emotion/styled": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", - "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "version": "11.10.5", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.10.5.tgz", + "integrity": "sha512-8EP6dD7dMkdku2foLoruPCNkRevzdcBaY6q0l0OsbyJK+x8D9HWjX27ARiSIKNF634hY9Zdoedh8bJCiva8yZw==", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.11.0", - "@emotion/is-prop-valid": "^1.2.1", - "@emotion/serialize": "^1.1.2", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@emotion/utils": "^1.2.1" + "@emotion/babel-plugin": "^11.10.5", + "@emotion/is-prop-valid": "^1.2.0", + "@emotion/serialize": "^1.1.1", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", + "@emotion/utils": "^1.2.0" }, "peerDependencies": { + "@babel/core": "^7.0.0", "@emotion/react": "^11.0.0-rc.0", "react": ">=16.8.0" }, "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, "@types/react": { "optional": true } } }, "node_modules/@emotion/unitless": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", + "integrity": "sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", - "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.0.tgz", + "integrity": "sha512-1eEgUGmkaljiBnRMTdksDV1W4kUnmwgp7X9G8B++9GYwl1lUdqSndSriIrTJ0N7LQaoauY9JJ2yhiOYK5+NI4A==", "peerDependencies": { "react": ">=16.8.0" } }, "node_modules/@emotion/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.0.tgz", + "integrity": "sha512-sn3WH53Kzpw8oQ5mgMmIzzyAaH2ZqFEbozVVBSYp538E06OSE6ytOp7pRAjNQR+Q/orwqdQYJSe2m3hCOeznkw==" }, "node_modules/@emotion/weak-memoize": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", - "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" - }, - "node_modules/@esbuild/android-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", - "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", - "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", - "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", - "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", - "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", - "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", - "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", - "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", - "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", - "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", - "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", - "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", - "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", - "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", - "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", - "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", - "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", - "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", - "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", - "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", - "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", - "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", - "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.5.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", - "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz", + "integrity": "sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==" }, "node_modules/@eslint/eslintrc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", - "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", - "dev": true, + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz", + "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.5.2", + "espree": "^9.4.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -919,11 +2255,15 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/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==" + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", - "dev": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -934,20 +2274,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/js": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.41.0.tgz", - "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==", - "dev": true, + "node_modules/@eslint/eslintrc/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==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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==", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", - "dev": true, "dependencies": { "@humanwhocodes/object-schema": "^1.2.1", "debug": "^4.1.1", @@ -961,7 +2313,6 @@ "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" }, @@ -973,18 +2324,686 @@ "node_modules/@humanwhocodes/object-schema": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/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==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/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==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/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==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/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==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/console/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/console/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/@jest/console/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/console/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==" + }, + "node_modules/@jest/console/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", + "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/reporters": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^27.5.1", + "jest-config": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-resolve-dependencies": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "jest-watcher": "^27.5.1", + "micromatch": "^4.0.4", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/core/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/@jest/core/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/core/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==" + }, + "node_modules/@jest/core/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "dependencies": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "dependencies": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", + "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", + "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.2", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-haste-map": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "slash": "^3.0.0", + "source-map": "^0.6.0", + "string-length": "^4.0.1", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^8.1.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/@jest/reporters/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/reporters/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==" + }, + "node_modules/@jest/reporters/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/reporters/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/reporters/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", + "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "dependencies": { + "@sinclair/typebox": "^0.24.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "dependencies": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/source-map/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/test-result": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", + "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", + "dependencies": { + "@jest/test-result": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-runtime": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "dependencies": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/transform/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/@jest/transform/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/transform/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==" + }, + "node_modules/@jest/transform/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/transform/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/transform/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/types/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/@jest/types/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/types/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==" + }, + "node_modules/@jest/types/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/types/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", + "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/set-array": "^1.0.0", + "@jridgewell/sourcemap-codec": "^1.4.10" }, "engines": { "node": ">=6.0.0" @@ -994,7 +3013,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -1003,43 +3021,61 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/source-map/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, "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==", - "dev": true + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", - "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", - "dev": true, + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", + "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", "dependencies": { "@jridgewell/resolve-uri": "3.1.0", "@jridgewell/sourcemap-codec": "1.4.14" } }, - "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, "node_modules/@mui/base": { - "version": "5.0.0-beta.2", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.2.tgz", - "integrity": "sha512-R9R+aqrl1QhZJaO05rhvooqxOaf7SKpQ+EjW80sbP3ticTVmLmrn4YBLQS7/ML+WXdrkrPtqSmKFdSE5Ik3gBQ==", + "version": "5.0.0-alpha.118", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.118.tgz", + "integrity": "sha512-GAEpqhnuHjRaAZLdxFNuOf2GDTp9sUawM46oHZV4VnYPFjXJDkIYFWfIQLONb0nga92OiqS5DD/scGzVKCL0Mw==", "dependencies": { - "@babel/runtime": "^7.21.0", - "@emotion/is-prop-valid": "^1.2.1", - "@mui/types": "^7.2.4", - "@mui/utils": "^5.13.1", - "@popperjs/core": "^2.11.7", + "@babel/runtime": "^7.20.13", + "@emotion/is-prop-valid": "^1.2.0", + "@mui/types": "^7.2.3", + "@mui/utils": "^5.11.9", + "@popperjs/core": "^2.11.6", "clsx": "^1.2.1", "prop-types": "^15.8.1", "react-is": "^18.2.0" @@ -1063,20 +3099,20 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.13.2", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.2.tgz", - "integrity": "sha512-aOLCXMCySMFL2WmUhnz+DjF84AoFVu8rn35OsL759HXOZMz8zhEwVf5w/xxkWx7DycM2KXDTgAvYW48nTfqTLA==", + "version": "5.11.9", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.11.9.tgz", + "integrity": "sha512-YGEtucQ/Nl91VZkzYaLad47Cdui51n/hW+OQm4210g4N3/nZzBxmGeKfubEalf+ShKH4aYDS86XTO6q/TpZnjQ==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui" } }, "node_modules/@mui/icons-material": { - "version": "5.11.16", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.11.16.tgz", - "integrity": "sha512-oKkx9z9Kwg40NtcIajF9uOXhxiyTZrrm9nmIJ4UjkU2IdHpd4QVLbCc/5hZN/y0C6qzi2Zlxyr9TGddQx2vx2A==", + "version": "5.11.9", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.11.9.tgz", + "integrity": "sha512-SPANMk6K757Q1x48nCwPGdSNb8B71d+2hPMJ0V12VWerpSsbjZtvAPi5FAn13l2O5mwWkvI0Kne+0tCgnNxMNw==", "dependencies": { - "@babel/runtime": "^7.21.0" + "@babel/runtime": "^7.20.13" }, "engines": { "node": ">=12.0.0" @@ -1097,19 +3133,19 @@ } }, "node_modules/@mui/material": { - "version": "5.13.2", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.2.tgz", - "integrity": "sha512-Pfke1l0GG2OJb/Nr10aVr8huoBFcBTdWKV5iFSTEHqf9c2C1ZlyYMISn7ui6X3Gix8vr+hP5kVqH1LAWwQSb6w==", + "version": "5.11.9", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.11.9.tgz", + "integrity": "sha512-Wb3WzjzYyi/WKSl/XlF7aC8kk2NE21IoHMF7hNQMkPb0GslbWwR4OUjlBpxtG+RSZn44wMZkEDNB9Hw0TDsd8g==", "dependencies": { - "@babel/runtime": "^7.21.0", - "@mui/base": "5.0.0-beta.2", - "@mui/core-downloads-tracker": "^5.13.2", - "@mui/system": "^5.13.2", - "@mui/types": "^7.2.4", - "@mui/utils": "^5.13.1", - "@types/react-transition-group": "^4.4.6", + "@babel/runtime": "^7.20.13", + "@mui/base": "5.0.0-alpha.118", + "@mui/core-downloads-tracker": "^5.11.9", + "@mui/system": "^5.11.9", + "@mui/types": "^7.2.3", + "@mui/utils": "^5.11.9", + "@types/react-transition-group": "^4.4.5", "clsx": "^1.2.1", - "csstype": "^3.1.2", + "csstype": "^3.1.1", "prop-types": "^15.8.1", "react-is": "^18.2.0", "react-transition-group": "^4.4.5" @@ -1141,12 +3177,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.13.1", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.13.1.tgz", - "integrity": "sha512-HW4npLUD9BAkVppOUZHeO1FOKUJWAwbpy0VQoGe3McUYTlck1HezGHQCfBQ5S/Nszi7EViqiimECVl9xi+/WjQ==", + "version": "5.11.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.11.9.tgz", + "integrity": "sha512-XMyVIFGomVCmCm92EvYlgq3zrC9K+J6r7IKl/rBJT2/xVYoRY6uM7jeB+Wxh7kXxnW9Dbqsr2yL3cx6wSD1sAg==", "dependencies": { - "@babel/runtime": "^7.21.0", - "@mui/utils": "^5.13.1", + "@babel/runtime": "^7.20.13", + "@mui/utils": "^5.11.9", "prop-types": "^15.8.1" }, "engines": { @@ -1167,13 +3203,13 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.13.2", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.13.2.tgz", - "integrity": "sha512-VCYCU6xVtXOrIN8lcbuPmoG+u7FYuOERG++fpY74hPpEWkyFQG97F+/XfTQVYzlR2m7nPjnwVUgATcTCMEaMvw==", + "version": "5.11.9", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.11.9.tgz", + "integrity": "sha512-bkh2CjHKOMy98HyOc8wQXEZvhOmDa/bhxMUekFX5IG0/w4f5HJ8R6+K6nakUUYNEgjOWPYzNPrvGB8EcGbhahQ==", "dependencies": { - "@babel/runtime": "^7.21.0", - "@emotion/cache": "^11.11.0", - "csstype": "^3.1.2", + "@babel/runtime": "^7.20.13", + "@emotion/cache": "^11.10.5", + "csstype": "^3.1.1", "prop-types": "^15.8.1" }, "engines": { @@ -1198,17 +3234,17 @@ } }, "node_modules/@mui/system": { - "version": "5.13.2", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.13.2.tgz", - "integrity": "sha512-TPyWmRJPt0JPVxacZISI4o070xEJ7ftxpVtu6LWuYVOUOINlhoGOclam4iV8PDT3EMQEHuUrwU49po34UdWLlw==", + "version": "5.11.9", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.11.9.tgz", + "integrity": "sha512-h6uarf+l3FO6l75Nf7yO+qDGrIoa1DM9nAMCUFZQsNCDKOInRzcptnm8M1w/Z3gVetfeeGoIGAYuYKbft6KZZA==", "dependencies": { - "@babel/runtime": "^7.21.0", - "@mui/private-theming": "^5.13.1", - "@mui/styled-engine": "^5.13.2", - "@mui/types": "^7.2.4", - "@mui/utils": "^5.13.1", + "@babel/runtime": "^7.20.13", + "@mui/private-theming": "^5.11.9", + "@mui/styled-engine": "^5.11.9", + "@mui/types": "^7.2.3", + "@mui/utils": "^5.11.9", "clsx": "^1.2.1", - "csstype": "^3.1.2", + "csstype": "^3.1.1", "prop-types": "^15.8.1" }, "engines": { @@ -1237,9 +3273,9 @@ } }, "node_modules/@mui/types": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.4.tgz", - "integrity": "sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.3.tgz", + "integrity": "sha512-tZ+CQggbe9Ol7e/Fs5RcKwg/woU+o8DCtOnccX6KmbBc7YrfqMYEYuaIcXHuhpT880QwNkZZ3wQwvtlDFA2yOw==", "peerDependencies": { "@types/react": "*" }, @@ -1250,13 +3286,13 @@ } }, "node_modules/@mui/utils": { - "version": "5.13.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.13.1.tgz", - "integrity": "sha512-6lXdWwmlUbEU2jUI8blw38Kt+3ly7xkmV9ljzY4Q20WhsJMWiNry9CX8M+TaP/HbtuyR8XKsdMgQW7h7MM3n3A==", + "version": "5.11.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.11.9.tgz", + "integrity": "sha512-eOJaqzcEs4qEwolcvFAmXGpln+uvouvOS9FUX6Wkrte+4I8rZbjODOBDVNlK+V6/ziTfD4iNKC0G+KfOTApbqg==", "dependencies": { - "@babel/runtime": "^7.21.0", + "@babel/runtime": "^7.20.13", "@types/prop-types": "^15.7.5", - "@types/react-is": "^18.2.0", + "@types/react-is": "^16.7.1 || ^17.0.0", "prop-types": "^15.8.1", "react-is": "^18.2.0" }, @@ -1271,11 +3307,38 @@ "react": "^17.0.0 || ^18.0.0" } }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "engines": { + "node": ">=4.0" + } + }, "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" @@ -1288,7 +3351,6 @@ "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" } @@ -1297,7 +3359,6 @@ "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" @@ -1306,43 +3367,625 @@ "node": ">= 8" } }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", + "integrity": "sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA==", + "dependencies": { + "ansi-html-community": "^0.0.8", + "common-path-prefix": "^3.0.0", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "find-up": "^5.0.0", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.4", + "schema-utils": "^3.0.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "@types/webpack": "4.x || 5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <4.0.0", + "webpack": ">=4.43.0 <6.0.0", + "webpack-dev-server": "3.x || 4.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "0.x || 1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "engines": { + "node": ">= 8" + } + }, "node_modules/@popperjs/core": { - "version": "2.11.7", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz", - "integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==", + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz", + "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" } }, "node_modules/@remix-run/router": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.6.2.tgz", - "integrity": "sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.2.tgz", + "integrity": "sha512-t54ONhl/h75X94SWsHGQ4G/ZrCEguKSRQr7DrjTciJXW0YU1QhlwYeycvK5JgkzlxmvrK7wq1NB/PLtHxoiDcA==", "engines": { "node": ">=14" } }, + "node_modules/@rollup/plugin-babel": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", + "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "@types/resolve": "1.17.1", + "builtin-modules": "^3.1.0", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", + "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==" + }, + "node_modules/@sinclair/typebox": { + "version": "0.24.51", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", + "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==" + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@surma/rollup-plugin-off-main-thread": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", + "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", + "dependencies": { + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", + "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", + "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", + "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", + "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", + "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", + "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", + "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", + "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", + "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", + "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", + "@svgr/babel-plugin-transform-svg-component": "^5.5.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", + "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", + "dependencies": { + "@svgr/plugin-jsx": "^5.5.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", + "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", + "dependencies": { + "@babel/types": "^7.12.6" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", + "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", + "dependencies": { + "@babel/core": "^7.12.3", + "@svgr/babel-preset": "^5.5.0", + "@svgr/hast-util-to-babel-ast": "^5.5.0", + "svg-parser": "^2.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz", + "integrity": "sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ==", + "dependencies": { + "cosmiconfig": "^7.0.0", + "deepmerge": "^4.2.2", + "svgo": "^1.2.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/webpack": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.5.0.tgz", + "integrity": "sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/plugin-transform-react-constant-elements": "^7.12.1", + "@babel/preset-env": "^7.12.1", + "@babel/preset-react": "^7.12.5", + "@svgr/core": "^5.5.0", + "@svgr/plugin-jsx": "^5.5.0", + "@svgr/plugin-svgo": "^5.5.0", + "loader-utils": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", + "integrity": "sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", + "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", + "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.18.3.tgz", + "integrity": "sha512-1kbcJ40lLB7MHsj39U4Sh1uTd2E7rLEa79kmDpI6cy+XiXsteB3POdQomoq4FxszMrO3ZYchkhYJw7A2862b3w==", + "dependencies": { + "@babel/types": "^7.3.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.10.tgz", + "integrity": "sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz", + "integrity": "sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.1.tgz", + "integrity": "sha512-rc9K8ZpVjNcLs8Fp0dkozd5Pt2Apk1glO4Vgz8ix1u6yFByxfqo5Yavpy65o+93TAe24jr7v+eSBtFLvOQtCRQ==", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", + "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", + "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" + }, + "node_modules/@types/express": { + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", + "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.33", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.33.tgz", + "integrity": "sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", + "integrity": "sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.9", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz", + "integrity": "sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", + "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", + "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" + }, + "node_modules/@types/mime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", + "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==" + }, + "node_modules/@types/node": { + "version": "18.13.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz", + "integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==" }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "node_modules/@types/prettier": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", + "integrity": "sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg==" + }, "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, + "node_modules/@types/q": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.5.tgz", + "integrity": "sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ==" + }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" + }, "node_modules/@types/react": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.7.tgz", - "integrity": "sha512-ojrXpSH2XFCmHm7Jy3q44nXDyN54+EYKP2lBhJ2bqfyPj6cIUW/FZW/Csdia34NQgq7KYcAlHi5184m4X88+yw==", + "version": "18.0.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.28.tgz", + "integrity": "sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -1350,49 +3993,570 @@ } }, "node_modules/@types/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-1vz2yObaQkLL7YFe/pme2cpvDsCwI1WXIfL+5eLz0MI9gFG24Re16RzUsI8t9XZn9ZWvgLNDrJBmrqXJO7GNQQ==", + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.3.tgz", + "integrity": "sha512-aBTIWg1emtu95bLTLx0cpkxwGW3ueZv71nE2YFBpL8k/z5czEW8yYpOo8Dp+UUAFAtKwNaOsh/ioSeQnWlZcfw==", "dependencies": { "@types/react": "*" } }, "node_modules/@types/react-transition-group": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz", - "integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA==", "dependencies": { "@types/react": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.0.0.tgz", - "integrity": "sha512-HX0XzMjL3hhOYm+0s95pb0Z7F8O81G7joUHgfDd/9J/ZZf5k4xX6QAMFkKsHFxaHlf6X7GD7+XuaZ66ULiJuhQ==", - "dev": true, + "node_modules/@types/resolve": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", + "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", "dependencies": { - "@babel/core": "^7.21.4", - "@babel/plugin-transform-react-jsx-self": "^7.21.0", - "@babel/plugin-transform-react-jsx-source": "^7.19.6", - "react-refresh": "^0.14.0" + "@types/node": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + }, + "node_modules/@types/scheduler": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" + }, + "node_modules/@types/semver": { + "version": "7.3.13", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", + "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==" + }, + "node_modules/@types/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==", + "dependencies": { + "@types/mime": "*", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.33", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", + "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", + "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" + }, + "node_modules/@types/ws": { + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", + "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "16.0.5", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.5.tgz", + "integrity": "sha512-AxO/ADJOBFJScHbWhq2xAhlWP24rY4aCEG/NFaMvbT3X2MgRsLjhjQwsn0Zi5zn0LG9jUhCCZMeX9Dkuw6k+vQ==", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", + "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.52.0.tgz", + "integrity": "sha512-lHazYdvYVsBokwCdKOppvYJKaJ4S41CgKBcPvyd0xjZNbvQdhn/pnJlGtQksQ/NhInzdaeaSarlBjDXHuclEbg==", + "dependencies": { + "@typescript-eslint/scope-manager": "5.52.0", + "@typescript-eslint/type-utils": "5.52.0", + "@typescript-eslint/utils": "5.52.0", + "debug": "^4.3.4", + "grapheme-splitter": "^1.0.4", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "regexpp": "^3.2.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "vite": "^4.2.0" + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.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==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "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==" + }, + "node_modules/@typescript-eslint/experimental-utils": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.52.0.tgz", + "integrity": "sha512-kd8CRr04mNE3hw4et6+0T0NI5vli2H6dJCGzjX1r12s/FXUehLVadmvo2Nl3DN80YqAh1cVC6zYZAkpmGiVJ5g==", + "dependencies": { + "@typescript-eslint/utils": "5.52.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.52.0.tgz", + "integrity": "sha512-e2KiLQOZRo4Y0D/b+3y08i3jsekoSkOYStROYmPUnGMEoA0h+k2qOH5H6tcjIc68WDvGwH+PaOrP1XRzLJ6QlA==", + "dependencies": { + "@typescript-eslint/scope-manager": "5.52.0", + "@typescript-eslint/types": "5.52.0", + "@typescript-eslint/typescript-estree": "5.52.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.52.0.tgz", + "integrity": "sha512-AR7sxxfBKiNV0FWBSARxM8DmNxrwgnYMPwmpkC1Pl1n+eT8/I2NAUPuwDy/FmDcC6F8pBfmOcaxcxRHspgOBMw==", + "dependencies": { + "@typescript-eslint/types": "5.52.0", + "@typescript-eslint/visitor-keys": "5.52.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.52.0.tgz", + "integrity": "sha512-tEKuUHfDOv852QGlpPtB3lHOoig5pyFQN/cUiZtpw99D93nEBjexRLre5sQZlkMoHry/lZr8qDAt2oAHLKA6Jw==", + "dependencies": { + "@typescript-eslint/typescript-estree": "5.52.0", + "@typescript-eslint/utils": "5.52.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.52.0.tgz", + "integrity": "sha512-oV7XU4CHYfBhk78fS7tkum+/Dpgsfi91IIDy7fjCyq2k6KB63M6gMC0YIvy+iABzmXThCRI6xpCEyVObBdWSDQ==", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.52.0.tgz", + "integrity": "sha512-WeWnjanyEwt6+fVrSR0MYgEpUAuROxuAH516WPjUblIrClzYJj0kBbjdnbQXLpgAN8qbEuGywiQsXUVDiAoEuQ==", + "dependencies": { + "@typescript-eslint/types": "5.52.0", + "@typescript-eslint/visitor-keys": "5.52.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.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==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "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==" + }, + "node_modules/@typescript-eslint/utils": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.52.0.tgz", + "integrity": "sha512-As3lChhrbwWQLNk2HC8Ree96hldKIqk98EYvypd3It8Q1f8d5zWyIoaZEp2va5667M4ZyE7X8UUR+azXrFl+NA==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.52.0", + "@typescript-eslint/types": "5.52.0", + "@typescript-eslint/typescript-estree": "5.52.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "engines": { + "node": ">=4.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==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "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==" + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.52.0.tgz", + "integrity": "sha512-qMwpw6SU5VHCPr99y274xhbm+PRViK/NATY6qzt+Et7+mThGuFSl/ompj2/hrBlRP/kq+BFdgagnOSgw9TB0eA==", + "dependencies": { + "@typescript-eslint/types": "5.52.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", + "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", + "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", + "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", + "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", + "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", + "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", + "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", + "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", + "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", + "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", + "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/helper-wasm-section": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-opt": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "@webassemblyjs/wast-printer": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", + "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", + "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", + "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", + "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/acorn": { "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -1400,20 +4564,106 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dependencies": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", + "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "peerDependencies": { + "acorn": "^8" + } + }, "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-node": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", + "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", + "dependencies": { + "acorn": "^7.0.0", + "acorn-walk": "^7.0.0", + "xtend": "^4.0.2" + } + }, + "node_modules/acorn-node/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", + "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "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==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "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", @@ -1425,11 +4675,79 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "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==", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" + } + }, "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" } @@ -1445,39 +4763,48 @@ "node": ">=4" } }, + "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==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "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==" + }, "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 + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } }, "node_modules/aria-query": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dev": true, "dependencies": { "deep-equal": "^2.0.5" } }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==" }, "node_modules/array-includes": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -1492,11 +4819,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.flat": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz", "integrity": "sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -1514,7 +4848,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -1528,11 +4861,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.reduce": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.5.tgz", + "integrity": "sha512-kDdugMl7id9COE8R7MHF5jWk7Dqt/fs4Pv+JXoICnYwqpjjjbUurz6w5fT5IG6brLdJhv6/VoHB0H7oyIBXd+Q==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.tosorted": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -1541,17 +4891,70 @@ "get-intrinsic": "^1.1.3" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", - "dev": true + "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==" + }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.13", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.13.tgz", + "integrity": "sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + } + ], + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-lite": "^1.0.30001426", + "fraction.js": "^4.2.0", + "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/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -1560,10 +4963,9 @@ } }, "node_modules/axe-core": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.1.tgz", - "integrity": "sha512-sCXXUhA+cljomZ3ZAwb8i1p3oOlkABzPy08ZDAoGcYuvtBPlQ1Ytde129ArXyHWDhfeewq7rlx9F+cUx2SSlkg==", - "dev": true, + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.6.3.tgz", + "integrity": "sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg==", "engines": { "node": ">=4" } @@ -1572,11 +4974,159 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", "integrity": "sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==", - "dev": true, "dependencies": { "deep-equal": "^2.0.5" } }, + "node_modules/babel-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", + "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", + "dependencies": { + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-jest/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/babel-jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/babel-jest/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/babel-jest/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==" + }, + "node_modules/babel-jest/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-jest/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-loader": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", + "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/babel-loader/node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", + "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.0.0", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -1591,27 +5141,260 @@ "npm": ">=6" } }, + "node_modules/babel-plugin-named-asset-import": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz", + "integrity": "sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q==", + "peerDependencies": { + "@babel/core": "^7.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz", + "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==", + "dependencies": { + "@babel/compat-data": "^7.17.7", + "@babel/helper-define-polyfill-provider": "^0.3.3", + "semver": "^6.1.1" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz", + "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.3.3", + "core-js-compat": "^3.25.1" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz", + "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.3.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-transform-react-remove-prop-types": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", + "integrity": "sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==" + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", + "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", + "dependencies": { + "babel-plugin-jest-hoist": "^27.5.1", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-react-app": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-react-app/-/babel-preset-react-app-10.0.1.tgz", + "integrity": "sha512-b0D9IZ1WhhCWkrTXyFuIIgqGzSkRIH5D5AmB0bXbzYAB1OBAwHcUeyWW2LorutLWF5btNo/N7r/cIdmvvKJlYg==", + "dependencies": { + "@babel/core": "^7.16.0", + "@babel/plugin-proposal-class-properties": "^7.16.0", + "@babel/plugin-proposal-decorators": "^7.16.4", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0", + "@babel/plugin-proposal-numeric-separator": "^7.16.0", + "@babel/plugin-proposal-optional-chaining": "^7.16.0", + "@babel/plugin-proposal-private-methods": "^7.16.0", + "@babel/plugin-transform-flow-strip-types": "^7.16.0", + "@babel/plugin-transform-react-display-name": "^7.16.0", + "@babel/plugin-transform-runtime": "^7.16.4", + "@babel/preset-env": "^7.16.4", + "@babel/preset-react": "^7.16.0", + "@babel/preset-typescript": "^7.16.0", + "@babel/runtime": "^7.16.3", + "babel-plugin-macros": "^3.1.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24" + } + }, "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 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==" + }, + "node_modules/bfj": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.0.2.tgz", + "integrity": "sha512-+e/UqUzwmzJamNF50tBV6tZPTORow7gQ96iFow+8b562OdMpEK0BcJEq2OSPEDmAbSMBQ7PKZ87ubFkgxpYWgw==", + "dependencies": { + "bluebird": "^3.5.5", + "check-types": "^11.1.1", + "hoopy": "^0.1.4", + "tryer": "^1.0.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "engines": { + "node": "*" + } + }, + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/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==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/bonjour-service": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.0.tgz", + "integrity": "sha512-LVRinRB3k1/K0XzZ2p58COnWvkQknIY6sf0zF2rpErvcJXpMBttEPQSxK+HEXSS9VmpZlDoDnQWv8ftJT20B0Q==", + "dependencies": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "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/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" + }, "node_modules/browserslist": { "version": "4.21.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz", "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1635,11 +5418,42 @@ "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==", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "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==" + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -1656,11 +5470,49 @@ "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==", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "engines": { + "node": ">=10" + }, + "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==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001489", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz", - "integrity": "sha512-x1mgZEXK8jHIfAxm+xgdpHpk50IN3z3q3zP261/WS+uvePxW8izXuCu6AHz0lkuYTlATDehiZ/tNyYBdSQsOUQ==", - "dev": true, + "version": "1.0.30001453", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001453.tgz", + "integrity": "sha512-R9o/uySW38VViaTrOtwfbFEiBFUh7ST3uIG4OEymIG3/uKdHDO4xk/FaqfUw0d+irSUyFPy3dZszf9VvSTPnsA==", "funding": [ { "type": "opencollective", @@ -1669,13 +5521,17 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" } ] }, + "node_modules/case-sensitive-paths-webpack-plugin": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", + "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==", + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -1697,6 +5553,112 @@ "node": ">=0.8.0" } }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/check-types": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.2.tgz", + "integrity": "sha512-HBiYvXvn9Z70Z88XKjz3AEKd4HJhBXsa3j7xFnITAzoS8+q6eIGi8qDB8FKPBAjtuxjI/zFpwuiCb8oDtKOYrA==" + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "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" + }, + "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==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", + "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", + "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==" + }, + "node_modules/clean-css": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz", + "integrity": "sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, "node_modules/clsx": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", @@ -1705,6 +5667,33 @@ "node": ">=6" } }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/coa": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", + "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", + "dependencies": { + "@types/q": "^1.5.1", + "chalk": "^2.4.1", + "q": "^1.1.2" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", + "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==" + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -1718,23 +5707,191 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + }, + "node_modules/colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==" + }, + "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==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dependencies": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "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 + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", - "dev": true + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==" + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/core-js": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.28.0.tgz", + "integrity": "sha512-GiZn9D4Z/rSYvTeg1ljAIsEqFm0LaN9gVtwDCrKL80zHtS31p9BAjmTxVqTQDMpwlMolJZOFntUG2uwyj7DAqw==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.28.0.tgz", + "integrity": "sha512-myzPgE7QodMg4nnd3K1TDoES/nADRStM8Gpz0D6nhkwbmwEnE0ZGJgoWsvQ722FR8D7xS0n0LV556RcEicjTyg==", + "dependencies": { + "browserslist": "^4.21.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-pure": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.28.0.tgz", + "integrity": "sha512-DSOVleA9/v3LNj/vFxAPfUHttKTzrB2RXhAPvR5TPXn4vrra3Z2ssytvRyt8eruJwAfwAiFADEbrjcRdcvPLQQ==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -1762,7 +5919,6 @@ "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", @@ -1772,22 +5928,474 @@ "node": ">= 8" } }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/css-blank-pseudo": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", + "integrity": "sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "bin": { + "css-blank-pseudo": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz", + "integrity": "sha512-fBffmak0bPAnyqc/HO8C3n2sHrp9wcqQz6ES9koRF2/mLOVAx9zIQ3Y7R29sYCteTPqMCwns4WYQoCX91Xl3+w==", + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-has-pseudo": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-3.0.4.tgz", + "integrity": "sha512-Vse0xpR1K9MNlp2j5w1pgWIJtm1a8qS0JwS9goFYcImjlHEmywP9VUF05aGBXzGpDJF86QXk4L0ypBmwPhGArw==", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "bin": { + "css-has-pseudo": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-loader": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.3.tgz", + "integrity": "sha512-qhOH1KlBMnZP8FzRO6YCH9UHXQhVMcEGLyNdb7Hv2cpcmJbW0YrddO+tG1ab5nT41KpHIYGsbeHqxB9xPu1pKQ==", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.19", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/css-loader/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==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-loader/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/css-minimizer-webpack-plugin": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", + "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", + "dependencies": { + "cssnano": "^5.0.6", + "jest-worker": "^27.0.2", + "postcss": "^8.3.5", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-prefers-color-scheme": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", + "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", + "bin": { + "css-prefers-color-scheme": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select-base-adapter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", + "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==" + }, + "node_modules/css-tree": { + "version": "1.0.0-alpha.37", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", + "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", + "dependencies": { + "mdn-data": "2.0.4", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssdb": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz", + "integrity": "sha512-0Q8NOMpXJ3iTDDbUv9grcmQAfdDx4qz+fN/+Md2FGbevT+6+bJNQ2LjB2YIUlLbpBTM32idU1Sb+tb/uGt6/XQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + }, + "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==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", + "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", + "dependencies": { + "cssnano-preset-default": "^5.2.14", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.14", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", + "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", + "dependencies": { + "css-declaration-sorter": "^6.3.1", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.1", + "postcss-convert-values": "^5.1.3", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.4", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.4", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.2", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, + "node_modules/csso/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cssom": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==" + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" + }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" + }, + "node_modules/data-urls": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", + "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "dependencies": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dependencies": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + }, + "engines": { + "node": ">=10" + } }, "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" }, @@ -1800,18 +6408,26 @@ } } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, + "node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==" + }, "node_modules/deep-equal": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.1.tgz", - "integrity": "sha512-lKdkdV6EOGoVn65XaOsPdH4rMxTZOnmFyuIkMjM1i5HHCbfjC97dawgTAy0deYNfuqUqW+Q5VrVaQYtUpSd6yQ==", - "dev": true, + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.0.tgz", + "integrity": "sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==", "dependencies": { - "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.2", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.0", + "es-get-iterator": "^1.1.2", + "get-intrinsic": "^1.1.3", "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", + "is-array-buffer": "^3.0.1", "is-date-object": "^1.0.5", "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", @@ -1819,7 +6435,7 @@ "object-is": "^1.1.5", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.0", + "regexp.prototype.flags": "^1.4.3", "side-channel": "^1.0.4", "which-boxed-primitive": "^1.0.2", "which-collection": "^1.0.1", @@ -1832,14 +6448,39 @@ "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 + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + }, + "node_modules/deepmerge": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz", + "integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "engines": { + "node": ">=8" + } }, "node_modules/define-properties": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", - "dev": true, "dependencies": { "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" @@ -1851,6 +6492,97 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defined": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" + }, + "node_modules/detect-port-alt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", + "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", + "dependencies": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "bin": { + "detect": "bin/detect-port", + "detect-port": "bin/detect-port" + }, + "engines": { + "node": ">= 4.2.1" + } + }, + "node_modules/detect-port-alt/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/detect-port-alt/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/detective": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz", + "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==", + "dependencies": { + "acorn-node": "^1.8.2", + "defined": "^1.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "detective": "bin/detective.js" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/dexie": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/dexie/-/dexie-3.2.3.tgz", @@ -1860,20 +6592,64 @@ } }, "node_modules/dexie-react-hooks": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dexie-react-hooks/-/dexie-react-hooks-1.1.3.tgz", - "integrity": "sha512-bXXE1gfYtfuVYTNiOlyam+YVaO8KaqacgRuxFuP37YtpS6o/jxT6KOl5h+hhqY36s0UavlHWbL+HWJFMcQumIg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/dexie-react-hooks/-/dexie-react-hooks-1.1.1.tgz", + "integrity": "sha512-Cam5JP6PxHN564RvWEoe8cqLhosW0O4CAZ9XEVYeGHJBa6KEJlOpd9CUpV3kmU9dm2MrW97/lk7qkf1xpij7gA==", "peerDependencies": { "@types/react": ">=16", "dexie": ">=3.1.0-alpha.1 <5.0.0", "react": ">=16" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.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==", + "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==" + }, + "node_modules/dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==" + }, + "node_modules/dns-packet": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.4.0.tgz", + "integrity": "sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g==", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "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" }, @@ -1881,6 +6657,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dependencies": { + "utila": "~0.4" + } + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1890,17 +6674,178 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domexception": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", + "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "dependencies": { + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/ejs": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.8.tgz", + "integrity": "sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { - "version": "1.4.407", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.407.tgz", - "integrity": "sha512-5smEvFSFYMv90tICOzRVP7Opp98DAC4KW7RRipg3BuNpGbbV3N+x24Zh3sbLb1T5haGtOSy/hrBfXsWnIM9aCg==", - "dev": true + "version": "1.4.300", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.300.tgz", + "integrity": "sha512-tHLIBkKaxvG6NnDWuLgeYrz+LTwAnApHm2R3KBNcRrFn0qLmTrqQeB4X4atfN6YJbkOOOSdRBeQ89OfFUelnEQ==" + }, + "node_modules/emittery": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", + "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } }, "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 + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz", + "integrity": "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } }, "node_modules/error-ex": { "version": "1.3.2", @@ -1919,18 +6864,17 @@ } }, "node_modules/es-abstract": { - "version": "1.21.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.21.2.tgz", - "integrity": "sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==", - "dev": true, + "version": "1.21.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.21.1.tgz", + "integrity": "sha512-QudMsPOz86xYz/1dG1OuGBKOELjCh99IIWHLzy5znUB6j8xG2yMA7bfTV86VSqKF+Y/H08vQPR+9jyXpuC6hfg==", "dependencies": { - "array-buffer-byte-length": "^1.0.0", "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.0", + "get-intrinsic": "^1.1.3", "get-symbol-description": "^1.0.0", "globalthis": "^1.0.3", "gopd": "^1.0.1", @@ -1938,8 +6882,8 @@ "has-property-descriptors": "^1.0.0", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", + "internal-slot": "^1.0.4", + "is-array-buffer": "^3.0.1", "is-callable": "^1.2.7", "is-negative-zero": "^2.0.2", "is-regex": "^1.1.4", @@ -1947,12 +6891,11 @@ "is-string": "^1.0.7", "is-typed-array": "^1.1.10", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", + "object-inspect": "^1.12.2", "object-keys": "^1.1.1", "object.assign": "^4.1.4", "regexp.prototype.flags": "^1.4.3", "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", "string.prototype.trimend": "^1.0.6", "string.prototype.trimstart": "^1.0.6", "typed-array-length": "^1.0.4", @@ -1966,11 +6909,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" + }, "node_modules/es-get-iterator": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -1986,11 +6933,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-module-lexer": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", + "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" + }, "node_modules/es-set-tostringtag": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", - "dev": true, "dependencies": { "get-intrinsic": "^1.1.3", "has": "^1.0.3", @@ -2004,7 +6955,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "dev": true, "dependencies": { "has": "^1.0.3" } @@ -2013,7 +6963,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, "dependencies": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -2026,52 +6975,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", - "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.17.19", - "@esbuild/android-arm64": "0.17.19", - "@esbuild/android-x64": "0.17.19", - "@esbuild/darwin-arm64": "0.17.19", - "@esbuild/darwin-x64": "0.17.19", - "@esbuild/freebsd-arm64": "0.17.19", - "@esbuild/freebsd-x64": "0.17.19", - "@esbuild/linux-arm": "0.17.19", - "@esbuild/linux-arm64": "0.17.19", - "@esbuild/linux-ia32": "0.17.19", - "@esbuild/linux-loong64": "0.17.19", - "@esbuild/linux-mips64el": "0.17.19", - "@esbuild/linux-ppc64": "0.17.19", - "@esbuild/linux-riscv64": "0.17.19", - "@esbuild/linux-s390x": "0.17.19", - "@esbuild/linux-x64": "0.17.19", - "@esbuild/netbsd-x64": "0.17.19", - "@esbuild/openbsd-x64": "0.17.19", - "@esbuild/sunos-x64": "0.17.19", - "@esbuild/win32-arm64": "0.17.19", - "@esbuild/win32-ia32": "0.17.19", - "@esbuild/win32-x64": "0.17.19" - } - }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, "engines": { "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2083,16 +6999,89 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.41.0.tgz", - "integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==", - "dev": true, + "node_modules/escodegen": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.3", - "@eslint/js": "8.41.0", + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/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==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/escodegen/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/eslint": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.34.0.tgz", + "integrity": "sha512-1Z8iFsucw+7kSqXNZVslXS8Ioa4u2KM7GPwuKtkTFAqZ/cHMcEaR+1+Br0wLlot49cNxIiZk5wp8EAbPcYZxTg==", + "dependencies": { + "@eslint/eslintrc": "^1.4.1", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -2102,22 +7091,24 @@ "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.1", - "espree": "^9.5.2", - "esquery": "^1.4.2", + "eslint-scope": "^7.1.1", + "eslint-utils": "^3.0.0", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.4.0", + "esquery": "^1.4.0", "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", + "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", + "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", @@ -2125,6 +7116,7 @@ "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.1", + "regexpp": "^3.2.0", "strip-ansi": "^6.0.1", "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" @@ -2139,63 +7131,37 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-config-airbnb": { - "version": "19.0.4", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-19.0.4.tgz", - "integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==", - "dev": true, + "node_modules/eslint-config-react-app": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", + "integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==", "dependencies": { - "eslint-config-airbnb-base": "^15.0.0", - "object.assign": "^4.1.2", - "object.entries": "^1.1.5" - }, - "engines": { - "node": "^10.12.0 || ^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^7.32.0 || ^8.2.0", + "@babel/core": "^7.16.0", + "@babel/eslint-parser": "^7.16.3", + "@rushstack/eslint-patch": "^1.1.0", + "@typescript-eslint/eslint-plugin": "^5.5.0", + "@typescript-eslint/parser": "^5.5.0", + "babel-preset-react-app": "^10.0.1", + "confusing-browser-globals": "^1.0.11", + "eslint-plugin-flowtype": "^8.0.3", "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jest": "^25.3.0", "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.28.0", - "eslint-plugin-react-hooks": "^4.3.0" - } - }, - "node_modules/eslint-config-airbnb-base": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", - "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", - "dev": true, - "dependencies": { - "confusing-browser-globals": "^1.0.10", - "object.assign": "^4.1.2", - "object.entries": "^1.1.5", - "semver": "^6.3.0" + "eslint-plugin-react": "^7.27.1", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-testing-library": "^5.0.1" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=14.0.0" }, "peerDependencies": { - "eslint": "^7.32.0 || ^8.2.0", - "eslint-plugin-import": "^2.25.2" - } - }, - "node_modules/eslint-config-prettier": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz", - "integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==", - "dev": true, - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" + "eslint": "^8.0.0" } }, "node_modules/eslint-import-resolver-node": { "version": "0.3.7", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz", "integrity": "sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==", - "dev": true, "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.11.0", @@ -2206,16 +7172,14 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-module-utils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", - "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", - "dev": true, + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz", + "integrity": "sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==", "dependencies": { "debug": "^3.2.7" }, @@ -2232,16 +7196,31 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, "dependencies": { "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-flowtype": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz", + "integrity": "sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==", + "dependencies": { + "lodash": "^4.17.21", + "string-natural-compare": "^3.0.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@babel/plugin-syntax-flow": "^7.14.5", + "@babel/plugin-transform-react-jsx": "^7.14.9", + "eslint": "^8.1.0" + } + }, "node_modules/eslint-plugin-import": { "version": "2.27.5", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", "integrity": "sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==", - "dev": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", @@ -2270,7 +7249,6 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, "dependencies": { "ms": "^2.1.1" } @@ -2279,7 +7257,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, "dependencies": { "esutils": "^2.0.2" }, @@ -2287,11 +7264,33 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-jest": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", + "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", + "dependencies": { + "@typescript-eslint/experimental-utils": "^5.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.7.1.tgz", "integrity": "sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==", - "dev": true, "dependencies": { "@babel/runtime": "^7.20.7", "aria-query": "^5.1.3", @@ -2321,7 +7320,6 @@ "version": "7.32.2", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.32.2.tgz", "integrity": "sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==", - "dev": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flatmap": "^1.3.1", @@ -2350,7 +7348,6 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true, "engines": { "node": ">=10" }, @@ -2362,7 +7359,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, "dependencies": { "esutils": "^2.0.2" }, @@ -2374,7 +7370,6 @@ "version": "2.0.0-next.4", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", - "dev": true, "dependencies": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -2387,39 +7382,177 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-plugin-testing-library": { + "version": "5.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.10.2.tgz", + "integrity": "sha512-f1DmDWcz5SDM+IpCkEX0lbFqrrTs8HRsEElzDEqN/EBI0hpRj8Cns5+IVANXswE8/LeybIJqPAOQIFu2j5Y5sw==", + "dependencies": { + "@typescript-eslint/utils": "^5.43.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0", + "npm": ">=6" + }, + "peerDependencies": { + "eslint": "^7.5.0 || ^8.0.0" + } + }, "node_modules/eslint-scope": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", - "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", - "dev": true, + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", + "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "engines": { + "node": ">=10" } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", - "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", - "dev": true, + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/eslint-webpack-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz", + "integrity": "sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==", + "dependencies": { + "@types/eslint": "^7.29.0 || ^8.4.1", + "jest-worker": "^28.0.2", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0", + "webpack": "^5.0.0" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/eslint-webpack-plugin/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/jest-worker": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz", + "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/eslint-webpack-plugin/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/eslint/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" }, @@ -2430,11 +7563,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/eslint/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==" + }, "node_modules/eslint/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" @@ -2450,7 +7587,6 @@ "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" }, @@ -2461,14 +7597,12 @@ "node_modules/eslint/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 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/eslint/node_modules/globals": { "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", - "dev": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -2483,16 +7617,25 @@ "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/eslint/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==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/eslint/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" }, @@ -2500,15 +7643,25 @@ "node": ">=8" } }, + "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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/espree": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", - "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", - "dev": true, + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz", + "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==", "dependencies": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^3.3.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -2517,11 +7670,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "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, + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.2.tgz", + "integrity": "sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng==", "dependencies": { "estraverse": "^5.1.0" }, @@ -2533,7 +7697,6 @@ "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" }, @@ -2545,52 +7708,219 @@ "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": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==" + }, "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/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "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 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "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==", + "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 + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "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 + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, "dependencies": { "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "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==", + "dependencies": { + "bser": "2.1.1" + } + }, "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" }, @@ -2598,6 +7928,117 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/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==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "engines": { + "node": ">= 0.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==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -2607,7 +8048,6 @@ "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" @@ -2623,7 +8063,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, "dependencies": { "flatted": "^3.1.0", "rimraf": "^3.0.2" @@ -2635,29 +8074,289 @@ "node_modules/flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", - "dev": true + "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" + }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, "dependencies": { "is-callable": "^1.1.3" } }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz", + "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==", + "dependencies": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "engines": { + "node": ">=10", + "yarn": ">=1.0.0" + }, + "peerDependencies": { + "eslint": ">= 6", + "typescript": ">= 2.7", + "vue-template-compiler": "*", + "webpack": ">= 4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + } + } + }, + "node_modules/fork-ts-checker-webpack-plugin/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/fork-ts-checker-webpack-plugin/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/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==" + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-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==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dependencies": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==" + }, "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 + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -2676,7 +8375,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3", @@ -2694,7 +8392,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2703,31 +8400,59 @@ "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==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "dev": true, + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", + "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", - "has-proto": "^1.0.1", "has-symbols": "^1.0.3" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==" + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "engines": { + "node": ">=8.0.0" + } + }, + "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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -2743,7 +8468,6 @@ "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", @@ -2763,7 +8487,6 @@ "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" }, @@ -2771,11 +8494,50 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "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" } @@ -2784,7 +8546,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", - "dev": true, "dependencies": { "define-properties": "^1.1.3" }, @@ -2795,11 +8556,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "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/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" }, @@ -2807,11 +8586,39 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" + }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==" + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==" + }, + "node_modules/harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==" }, "node_modules/has": { "version": "1.0.3", @@ -2828,7 +8635,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2845,7 +8651,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "dev": true, "dependencies": { "get-intrinsic": "^1.1.1" }, @@ -2857,7 +8662,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -2869,7 +8673,6 @@ "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" }, @@ -2881,7 +8684,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -2892,6 +8694,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -2905,6 +8715,98 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/hoopy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", + "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", + "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "dependencies": { + "whatwg-encoding": "^1.0.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -2913,6 +8815,140 @@ "void-elements": "3.1.0" } }, + "node_modules/html-webpack-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "webpack": "^5.20.0" + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "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==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/humanize-duration": { "version": "3.28.0", "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.28.0.tgz", @@ -2956,15 +8992,61 @@ "cross-fetch": "3.1.5" } }, + "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==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, + "node_modules/identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "dependencies": { + "harmony-reflect": "^1.4.6" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", - "dev": true, "engines": { "node": ">= 4" } }, + "node_modules/immer": { + "version": "9.0.19", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.19.tgz", + "integrity": "sha512-eY+Y0qcsB4TZKwgQzLaE/lqYMlKhv5J9dyd2RhhtGhNo2njPXDqU9XPfcNfa3MIDsdtZt5KlkIsirlo4dHsWdQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -2980,11 +9062,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "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" } @@ -2993,7 +9092,6 @@ "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" @@ -3002,14 +9100,17 @@ "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 + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, "node_modules/internal-slot": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", - "dev": true, "dependencies": { "get-intrinsic": "^1.2.0", "has": "^1.0.3", @@ -3019,11 +9120,18 @@ "node": ">= 0.4" } }, + "node_modules/ipaddr.js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", + "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -3036,13 +9144,12 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", - "dev": true, + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.1.tgz", + "integrity": "sha512-ASfLknmY8Xa2XtB4wmbz13Wu202baeA18cJBCeCy0wXUHZF0IPyVEXqKEcd+t2fNSLLL1vC6k7lxZEojNbISXQ==", "dependencies": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", + "get-intrinsic": "^1.1.3", "is-typed-array": "^1.1.10" }, "funding": { @@ -3058,7 +9165,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, "dependencies": { "has-bigints": "^1.0.1" }, @@ -3066,11 +9172,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -3086,7 +9202,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -3095,9 +9210,9 @@ } }, "node_modules/is-core-module": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", - "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", + "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", "dependencies": { "has": "^1.0.3" }, @@ -3109,7 +9224,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -3120,20 +9234,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "engines": { + "node": ">=6" + } + }, "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" }, @@ -3145,16 +9287,19 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" + }, "node_modules/is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -3162,11 +9307,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-number-object": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -3177,20 +9329,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "engines": { + "node": ">=0.10.0" + } + }, "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-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -3202,11 +9376,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-root": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", + "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", + "engines": { + "node": ">=6" + } + }, "node_modules/is-set": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3215,7 +9404,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2" }, @@ -3223,11 +9411,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -3242,7 +9440,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -3257,7 +9454,6 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", - "dev": true, "dependencies": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", @@ -3272,11 +9468,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + }, "node_modules/is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3285,7 +9485,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.2" }, @@ -3297,7 +9496,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -3306,45 +9504,2202 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "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 + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", + "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jake": { + "version": "10.8.5", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", + "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.1", + "minimatch": "^3.0.4" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jake/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jake/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jake/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jake/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==" + }, + "node_modules/jake/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jake/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "dependencies": { + "@jest/core": "^27.5.1", + "import-local": "^3.0.2", + "jest-cli": "^27.5.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", + "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", + "dependencies": { + "@jest/types": "^27.5.1", + "execa": "^5.0.0", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", + "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-circus/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-circus/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==" + }, + "node_modules/jest-circus/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-circus/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", + "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", + "dependencies": { + "@jest/core": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "import-local": "^3.0.2", + "jest-config": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "prompts": "^2.0.1", + "yargs": "^16.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-cli/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-cli/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-cli/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==" + }, + "node_modules/jest-cli/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-config": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", + "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", + "dependencies": { + "@babel/core": "^7.8.0", + "@jest/test-sequencer": "^27.5.1", + "@jest/types": "^27.5.1", + "babel-jest": "^27.5.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.1", + "graceful-fs": "^4.2.9", + "jest-circus": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-jasmine2": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-config/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-config/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==" + }, + "node_modules/jest-config/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-config/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-diff/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-diff/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-diff/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==" + }, + "node_modules/jest-diff/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-diff/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-docblock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", + "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-each/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-each/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==" + }, + "node_modules/jest-each/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-each/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", + "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1", + "jsdom": "^16.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", + "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-jasmine2": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", + "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-jasmine2/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-jasmine2/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-jasmine2/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==" + }, + "node_modules/jest-jasmine2/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-jasmine2/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-leak-detector": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", + "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", + "dependencies": { + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-matcher-utils/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-matcher-utils/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==" + }, + "node_modules/jest-matcher-utils/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-matcher-utils/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-message-util/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-message-util/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-message-util/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==" + }, + "node_modules/jest-message-util/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-message-util/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", + "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-snapshot": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-resolve/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-resolve/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-resolve/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==" + }, + "node_modules/jest-resolve/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-resolve/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runner": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", + "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-leak-detector": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "source-map-support": "^0.5.6", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-runner/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-runner/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-runner/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==" + }, + "node_modules/jest-runner/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runner/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-runtime/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-runtime/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-runtime/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==" + }, + "node_modules/jest-runtime/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runtime/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "dependencies": { + "@types/node": "*", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "dependencies": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-snapshot/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-snapshot/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==" + }, + "node_modules/jest-snapshot/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-snapshot/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==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-snapshot/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-snapshot/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-util/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-util/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-util/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==" + }, + "node_modules/jest-util/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-util/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "dependencies": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-validate/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-validate/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==" + }, + "node_modules/jest-validate/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-validate/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.1.0.tgz", + "integrity": "sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw==", + "dependencies": { + "ansi-escapes": "^4.3.1", + "chalk": "^4.0.0", + "jest-regex-util": "^28.0.0", + "jest-watcher": "^28.0.0", + "slash": "^4.0.0", + "string-length": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "jest": "^27.0.0 || ^28.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-28.1.3.tgz", + "integrity": "sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==", + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3", + "slash": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/test-result": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-28.1.3.tgz", + "integrity": "sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==", + "dependencies": { + "@jest/console": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/types": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", + "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", + "dependencies": { + "@jest/schemas": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@types/yargs": { + "version": "17.0.22", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.22.tgz", + "integrity": "sha512-pet5WJ9U8yPVRhkwuEIp5ktAeAqRZOq4UdAyWLWzxbtpyXnzbtLdKiXAjJzi/KLmPGS9wk86lUFWZFN6sISo4g==", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-watch-typeahead/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-watch-typeahead/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-watch-typeahead/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==" + }, + "node_modules/jest-watch-typeahead/node_modules/emittery": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", + "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", + "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^28.1.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-regex-util": { + "version": "28.0.2", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-28.0.2.tgz", + "integrity": "sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==", + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", + "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-28.1.3.tgz", + "integrity": "sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==", + "dependencies": { + "@jest/test-result": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.10.2", + "jest-util": "^28.1.3", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/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==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dependencies": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", + "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "dependencies": { + "char-regex": "^2.0.0", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length/node_modules/char-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", + "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi/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==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watcher": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", + "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", + "dependencies": { + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "jest-util": "^27.5.1", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-watcher/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watcher/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/jest-watcher/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-watcher/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==" + }, + "node_modules/jest-watcher/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watcher/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } }, "node_modules/js-base64": { "version": "3.7.5", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.5.tgz", "integrity": "sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==" }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "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==" }, "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, + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dependencies": { - "argparse": "^2.0.1" + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", + "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.2.4", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.3.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.1", + "domexception": "^2.0.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", + "html-encoding-sniffer": "^2.0.1", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dependencies": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + }, + "engines": { + "node": ">=10" + } + }, "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" }, @@ -3357,23 +11712,25 @@ "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==" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, "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 + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "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 + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" }, "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" }, @@ -3381,11 +11738,29 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", - "dev": true, "dependencies": { "array-includes": "^3.1.5", "object.assign": "^4.1.3" @@ -3394,26 +11769,55 @@ "node": ">=4.0" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "engines": { + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "engines": { + "node": ">= 8" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.22", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", - "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", - "dev": true + "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==" }, "node_modules/language-tags": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", - "dev": true, "dependencies": { "language-subtag-registry": "~0.3.2" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "engines": { + "node": ">=6" + } + }, "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" @@ -3422,16 +11826,44 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz", + "integrity": "sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==", + "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==" }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, "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" }, @@ -3442,11 +11874,35 @@ "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==" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + }, "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 + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "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==" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" }, "node_modules/loose-envify": { "version": "1.4.0", @@ -3459,20 +11915,228 @@ "loose-envify": "cli.js" } }, + "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==", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, "dependencies": { "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/mdn-data": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", + "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.13.tgz", + "integrity": "sha512-omTM41g3Skpvx5dSYeZIbXKcXoAVc/AoMNwn9TKx++L/gaen/+4TTttmu8ZSch5vfVJ8uJvGbroTsIlslRg6lg==", + "dependencies": { + "fs-monkey": "^1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "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==" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz", + "integrity": "sha512-EdlUizq13o0Pd+uCp+WO/JpkLvHRVGt97RqfeGhXqAcorYo1ypJSpkV+WDT0vY/kmh/p7wRdJNJtuyK540PXDw==", + "dependencies": { + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "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" }, @@ -3484,28 +12148,42 @@ "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/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "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 + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3516,8 +12194,34 @@ "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 + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + }, + "node_modules/natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==" + }, + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, + "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==", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } }, "node_modules/node-fetch": { "version": "2.6.7", @@ -3538,11 +12242,77 @@ } } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "engines": { + "node": ">= 6.13.0" + } + }, + "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==" + }, "node_modules/node-releases": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.12.tgz", - "integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==", - "dev": true + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", + "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==" + }, + "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==", + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.2.tgz", + "integrity": "sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==" }, "node_modules/object-assign": { "version": "4.1.1", @@ -3552,11 +12322,18 @@ "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==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3565,7 +12342,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3" @@ -3581,7 +12357,6 @@ "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" } @@ -3590,7 +12365,6 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -3608,7 +12382,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -3622,7 +12395,6 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -3635,11 +12407,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.getownpropertydescriptors": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.5.tgz", + "integrity": "sha512-yDNzckpM6ntyQiGTik1fKV1DcVDRS+w8bvpWNCBanvH5LfRX9O8WTHqQzG4RZwRAM4I0oU7TV11Lj5v0g20ibw==", + "dependencies": { + "array.prototype.reduce": "^1.0.5", + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object.hasown": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", "integrity": "sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==", - "dev": true, "dependencies": { "define-properties": "^1.1.4", "es-abstract": "^1.20.4" @@ -3652,7 +12440,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -3665,20 +12452,72 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "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==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.1.tgz", + "integrity": "sha512-/4b7qZNhv6Uhd7jjnREh1NjnPxlTq+XNWPG88Ydkj5AILcA5m3ajvcg57pB24EQjKv0dK62XnDqk9c/hkIG5Kg==", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -3695,7 +12534,6 @@ "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" }, @@ -3710,7 +12548,6 @@ "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" }, @@ -3721,6 +12558,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "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", @@ -3749,11 +12615,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dependencies": { + "no-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" } @@ -3762,7 +12649,6 @@ "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" } @@ -3771,7 +12657,6 @@ "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" } @@ -3781,6 +12666,11 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -3789,17 +12679,173 @@ "node": ">=8" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" + }, "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 + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", + "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/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==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/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==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/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==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/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==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/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==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "engines": { + "node": ">=4" + } }, "node_modules/postcss": { - "version": "8.4.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", - "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", - "dev": true, + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", "funding": [ { "type": "opencollective", @@ -3808,14 +12854,10 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "nanoid": "^3.3.6", + "nanoid": "^3.3.4", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -3823,28 +12865,1260 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-attribute-case-insensitive": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz", + "integrity": "sha512-XIidXV8fDr0kKt28vqki84fRK8VW8eTuIa4PChv2MqKuT6C9UjmSKzen6KaWhWEoYvwxFCa7n/tC1SZ3tyq4SQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-browser-comments": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-browser-comments/-/postcss-browser-comments-4.0.0.tgz", + "integrity": "sha512-X9X9/WN3KIvY9+hNERUqX9gncsgBA25XaeR+jshHz2j8+sYyHktHw1JdKuMjeLpGktXidqDhA7b/qm1mrBDmgg==", + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "browserslist": ">=4", + "postcss": ">=8" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-clamp": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", + "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=7.6.0" + }, + "peerDependencies": { + "postcss": "^8.4.6" + } + }, + "node_modules/postcss-color-functional-notation": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-4.2.4.tgz", + "integrity": "sha512-2yrTAUZUab9s6CpxkxC4rVgFEVaR6/2Pipvi6qcgvnYiVqZcbDHEoBDhrXzyb7Efh2CCfHQNtcqWcIruDTIUeg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-color-hex-alpha": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-8.0.4.tgz", + "integrity": "sha512-nLo2DCRC9eE4w2JmuKgVA3fGL3d01kGq752pVALF68qpGLmx2Qrk91QTKkdUqqp45T1K1XV8IhQpcu1hoAQflQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-rebeccapurple": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-7.1.1.tgz", + "integrity": "sha512-pGxkuVEInwLHgkNxUc4sdg4g3py7zUeCQ9sMfwyHAT+Ezk8a4OaaVZ8lIY5+oNqA/BXXgLyXv0+5wHP68R79hg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", + "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", + "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-custom-media": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-8.0.2.tgz", + "integrity": "sha512-7yi25vDAoHAkbhAzX9dHx2yc6ntS4jQvejrNcC+csQJAXjj15e7VcWfMgLqBNAbOvqi5uIa9huOVwdHbf+sKqg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/postcss-custom-properties": { + "version": "12.1.11", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-12.1.11.tgz", + "integrity": "sha512-0IDJYhgU8xDv1KY6+VgUwuQkVtmYzRwu+dMjnmdMafXYv86SWqfxkc7qdDvWS38vsjaEtv8e0vGOUQrAiMBLpQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-custom-selectors": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-6.0.3.tgz", + "integrity": "sha512-fgVkmyiWDwmD3JbpCmB45SvvlCD6z9CG6Ie6Iere22W5aHea6oWa7EM2bpnv2Fj3I94L3VbtvX9KqwSi5aFzSg==", + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/postcss-dir-pseudo-class": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-6.0.5.tgz", + "integrity": "sha512-eqn4m70P031PF7ZQIvSgy9RSJ5uI2171O/OO/zcRNYpJbvaeKFUlar1aJ7rmgiQtbm0FSPsRewjpdS0Oew7MPA==", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", + "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-double-position-gradients": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-3.1.2.tgz", + "integrity": "sha512-GX+FuE/uBR6eskOK+4vkXgT6pDkexLokPaz/AbJna9s5Kzp/yl488pKPjhy0obB475ovfT1Wv8ho7U/cHNaRgQ==", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-env-function": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/postcss-env-function/-/postcss-env-function-4.0.6.tgz", + "integrity": "sha512-kpA6FsLra+NqcFnL81TnsU+Z7orGtDTxcOhl6pwXeEq1yFPpRMkCDpHhrz8CFQDr/Wfm0jLiNQ1OsGGPjlqPwA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-flexbugs-fixes": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", + "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", + "peerDependencies": { + "postcss": "^8.1.4" + } + }, + "node_modules/postcss-focus-visible": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-6.0.4.tgz", + "integrity": "sha512-QcKuUU/dgNsstIK6HELFRT5Y3lbrMLEOwG+A4s5cA+fx3A3y/JTq3X9LaOj3OC3ALH0XqyrgQIgey/MIZ8Wczw==", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-within": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-5.0.4.tgz", + "integrity": "sha512-vvjDN++C0mu8jz4af5d52CB184ogg/sSxAFS+oUJQq2SuCe7T5U2iIsVJtsCp2d6R4j0jr5+q3rPkBVZkXD9fQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-font-variant": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", + "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-gap-properties": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.5.tgz", + "integrity": "sha512-IuE6gKSdoUNcvkGIqdtjtcMtZIFyXZhmFd5RUlg97iVEvp1BZKV5ngsAjCjrVy+14uhGBQl9tzmi1Qwq4kqVOg==", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-image-set-function": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-4.0.7.tgz", + "integrity": "sha512-9T2r9rsvYzm5ndsBE8WgtrMlIT7VbtTfE7b3BQnudUqnBcBo7L758oc+o+pdj/dUV0l5wjwSdjeOH2DZtfv8qw==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-import": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz", + "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-initial": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", + "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", + "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==", + "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-lab-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-4.2.1.tgz", + "integrity": "sha512-xuXll4isR03CrQsmxyz92LJB2xX9n+pZJ5jE9JgcnmsCammLyKdlzrBin+25dy6wIjfhJpKBAN80gsTlCgRk2w==", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "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==", + "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-loader": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", + "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", + "dependencies": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.5", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-loader/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==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postcss-loader/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postcss-loader/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/postcss-logical": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", + "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-media-minmax": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", + "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", + "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", + "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", + "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "dependencies": { + "browserslist": "^4.21.4", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", + "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nested": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.0.tgz", + "integrity": "sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nesting": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-10.2.0.tgz", + "integrity": "sha512-EwMkYchxiDiKUhlJGzWsD9b2zvq/r2SSubcRrgP+jujMXFzqvANLt16lJANC+5uZ6hjI7lpRmI6O8JIl+8l1KA==", + "dependencies": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-normalize": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize/-/postcss-normalize-10.0.1.tgz", + "integrity": "sha512-+5w18/rDev5mqERcG3W5GZNMJa1eoYYNGo8gB7tEwaos0ajk3ZXAI4mHGcNT47NE+ZnZD1pEpUOFLvltIwmeJA==", + "dependencies": { + "@csstools/normalize.css": "*", + "postcss-browser-comments": "^4", + "sanitize.css": "*" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "browserslist": ">= 4", + "postcss": ">= 8" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", + "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", + "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", + "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-opacity-percentage": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-1.1.3.tgz", + "integrity": "sha512-An6Ba4pHBiDtyVpSLymUUERMo2cU7s+Obz6BTrS+gxkbnSBNKSuD0AVUc+CpBMrpVPKKfoVz0WQCX+Tnst0i4A==", + "funding": [ + { + "type": "kofi", + "url": "https://ko-fi.com/mrcgrtz" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/mrcgrtz" + } + ], + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", + "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-overflow-shorthand": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.4.tgz", + "integrity": "sha512-otYl/ylHK8Y9bcBnPLo3foYFLL6a6Ak+3EQBPOTR7luMYCOsiVTUk1iLvNf6tVPNGXcoL9Hoz37kpfriRIFb4A==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-page-break": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", + "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", + "peerDependencies": { + "postcss": "^8" + } + }, + "node_modules/postcss-place": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-7.0.5.tgz", + "integrity": "sha512-wR8igaZROA6Z4pv0d+bvVrvGY4GVHihBCBQieXFY3kuSuMyOmEnnfFzHl/tQuqHZkfkIVBEbDvYcFfHmpSet9g==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-preset-env": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.8.3.tgz", + "integrity": "sha512-T1LgRm5uEVFSEF83vHZJV2z19lHg4yJuZ6gXZZkqVsqv63nlr6zabMH3l4Pc01FQCyfWVrh2GaUeCVy9Po+Aag==", + "dependencies": { + "@csstools/postcss-cascade-layers": "^1.1.1", + "@csstools/postcss-color-function": "^1.1.1", + "@csstools/postcss-font-format-keywords": "^1.0.1", + "@csstools/postcss-hwb-function": "^1.0.2", + "@csstools/postcss-ic-unit": "^1.0.1", + "@csstools/postcss-is-pseudo-class": "^2.0.7", + "@csstools/postcss-nested-calc": "^1.0.0", + "@csstools/postcss-normalize-display-values": "^1.0.1", + "@csstools/postcss-oklab-function": "^1.1.1", + "@csstools/postcss-progressive-custom-properties": "^1.3.0", + "@csstools/postcss-stepped-value-functions": "^1.0.1", + "@csstools/postcss-text-decoration-shorthand": "^1.0.0", + "@csstools/postcss-trigonometric-functions": "^1.0.2", + "@csstools/postcss-unset-value": "^1.0.2", + "autoprefixer": "^10.4.13", + "browserslist": "^4.21.4", + "css-blank-pseudo": "^3.0.3", + "css-has-pseudo": "^3.0.4", + "css-prefers-color-scheme": "^6.0.3", + "cssdb": "^7.1.0", + "postcss-attribute-case-insensitive": "^5.0.2", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^4.2.4", + "postcss-color-hex-alpha": "^8.0.4", + "postcss-color-rebeccapurple": "^7.1.1", + "postcss-custom-media": "^8.0.2", + "postcss-custom-properties": "^12.1.10", + "postcss-custom-selectors": "^6.0.3", + "postcss-dir-pseudo-class": "^6.0.5", + "postcss-double-position-gradients": "^3.1.2", + "postcss-env-function": "^4.0.6", + "postcss-focus-visible": "^6.0.4", + "postcss-focus-within": "^5.0.4", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^3.0.5", + "postcss-image-set-function": "^4.0.7", + "postcss-initial": "^4.0.1", + "postcss-lab-function": "^4.2.1", + "postcss-logical": "^5.0.4", + "postcss-media-minmax": "^5.0.0", + "postcss-nesting": "^10.2.0", + "postcss-opacity-percentage": "^1.1.2", + "postcss-overflow-shorthand": "^3.0.4", + "postcss-page-break": "^3.0.4", + "postcss-place": "^7.0.5", + "postcss-pseudo-class-any-link": "^7.1.6", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-pseudo-class-any-link": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-7.1.6.tgz", + "integrity": "sha512-9sCtZkO6f/5ML9WcTLcIyV1yz9D1rf0tWc+ulKcvV30s0iZKS/ONyETvoWsr6vnrmW+X+KmuK3gV/w5EWnT37w==", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", + "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-replace-overflow-wrap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", + "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", + "peerDependencies": { + "postcss": "^8.0.3" + } + }, + "node_modules/postcss-selector-not": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-6.0.1.tgz", + "integrity": "sha512-1i9affjAe9xu/y9uqWH+tD4r6/hDaXJruk8xn2x1vzxC2U3J3LKO3zJW4CyxlNhA56pADJ/djpEwpH1RClI2rQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz", + "integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/postcss-svgo/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/postcss-svgo/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, + "node_modules/postcss-svgo/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postcss-svgo/node_modules/svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "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==" + }, "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": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "engines": { - "node": ">=10.13.0" + "node": ">=6" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" } }, "node_modules/prop-types": { @@ -3862,20 +14136,71 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true, "engines": { "node": ">=6" } }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "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", @@ -3891,6 +14216,74 @@ } ] }, + "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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/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==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -3902,6 +14295,128 @@ "node": ">=0.10.0" } }, + "node_modules/react-app-polyfill": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz", + "integrity": "sha512-sZ41cxiU5llIB003yxxQBYrARBqe0repqPTTYBTmMqTz9szeBbE37BehCE891NZsmdZqqP+xWKdT3eo3vOzN8w==", + "dependencies": { + "core-js": "^3.19.2", + "object-assign": "^4.1.1", + "promise": "^8.1.0", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.9", + "whatwg-fetch": "^3.6.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", + "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", + "dependencies": { + "@babel/code-frame": "^7.16.0", + "address": "^1.1.2", + "browserslist": "^4.18.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "detect-port-alt": "^1.1.6", + "escape-string-regexp": "^4.0.0", + "filesize": "^8.0.6", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^6.5.0", + "global-modules": "^2.0.0", + "globby": "^11.0.4", + "gzip-size": "^6.0.0", + "immer": "^9.0.7", + "is-root": "^2.1.0", + "loader-utils": "^3.2.0", + "open": "^8.4.0", + "pkg-up": "^3.1.0", + "prompts": "^2.4.2", + "react-error-overlay": "^6.0.11", + "recursive-readdir": "^2.2.2", + "shell-quote": "^1.7.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/react-dev-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/react-dev-utils/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/react-dev-utils/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==" + }, + "node_modules/react-dev-utils/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/react-dev-utils/node_modules/loader-utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", + "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/react-dev-utils/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -3914,6 +14429,11 @@ "react": "^18.2.0" } }, + "node_modules/react-error-overlay": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", + "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" + }, "node_modules/react-i18next": { "version": "11.18.6", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz", @@ -3952,20 +14472,19 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "node_modules/react-refresh": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", - "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", - "dev": true, + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", + "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "engines": { "node": ">=0.10.0" } }, "node_modules/react-router": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.11.2.tgz", - "integrity": "sha512-74z9xUSaSX07t3LM+pS6Un0T55ibUE/79CzfZpy5wsPDZaea1F8QkrsiyRnA2YQ7LwE/umaydzXZV80iDCPkMg==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.1.tgz", + "integrity": "sha512-Jgi8BzAJQ8MkPt8ipXnR73rnD7EmZ0HFFb7jdQU24TynGW1Ooqin2KVDN9voSC+7xhqbbCd2cjGUepb6RObnyg==", "dependencies": { - "@remix-run/router": "1.6.2" + "@remix-run/router": "1.3.2" }, "engines": { "node": ">=14" @@ -3975,12 +14494,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.11.2.tgz", - "integrity": "sha512-JNbKtAeh1VSJQnH6RvBDNhxNwemRj7KxCzc5jb7zvDSKRnPWIFj9pO+eXqjM69gQJ0r46hSz1x4l9y0651DKWw==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.8.1.tgz", + "integrity": "sha512-67EXNfkQgf34P7+PSb6VlBuaacGhkKn3kpE51+P6zYSG2kiRoumXEL6e27zTa9+PGF2MNXbgIUHTVlleLbIcHQ==", "dependencies": { - "@remix-run/router": "1.6.2", - "react-router": "6.11.2" + "@remix-run/router": "1.3.2", + "react-router": "6.8.1" }, "engines": { "node": ">=14" @@ -3990,6 +14509,108 @@ "react-dom": ">=16.8" } }, + "node_modules/react-scripts": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", + "integrity": "sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==", + "dependencies": { + "@babel/core": "^7.16.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", + "@svgr/webpack": "^5.5.0", + "babel-jest": "^27.4.2", + "babel-loader": "^8.2.3", + "babel-plugin-named-asset-import": "^0.3.8", + "babel-preset-react-app": "^10.0.1", + "bfj": "^7.0.2", + "browserslist": "^4.18.1", + "camelcase": "^6.2.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "css-loader": "^6.5.1", + "css-minimizer-webpack-plugin": "^3.2.0", + "dotenv": "^10.0.0", + "dotenv-expand": "^5.1.0", + "eslint": "^8.3.0", + "eslint-config-react-app": "^7.0.1", + "eslint-webpack-plugin": "^3.1.1", + "file-loader": "^6.2.0", + "fs-extra": "^10.0.0", + "html-webpack-plugin": "^5.5.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^27.4.3", + "jest-resolve": "^27.4.2", + "jest-watch-typeahead": "^1.0.0", + "mini-css-extract-plugin": "^2.4.5", + "postcss": "^8.4.4", + "postcss-flexbugs-fixes": "^5.0.2", + "postcss-loader": "^6.2.1", + "postcss-normalize": "^10.0.1", + "postcss-preset-env": "^7.0.1", + "prompts": "^2.4.2", + "react-app-polyfill": "^3.0.0", + "react-dev-utils": "^12.0.1", + "react-refresh": "^0.11.0", + "resolve": "^1.20.0", + "resolve-url-loader": "^4.0.0", + "sass-loader": "^12.3.0", + "semver": "^7.3.5", + "source-map-loader": "^3.0.0", + "style-loader": "^3.3.1", + "tailwindcss": "^3.0.2", + "terser-webpack-plugin": "^5.2.5", + "webpack": "^5.64.4", + "webpack-dev-server": "^4.6.0", + "webpack-manifest-plugin": "^4.0.2", + "workbox-webpack-plugin": "^6.4.1" + }, + "bin": { + "react-scripts": "bin/react-scripts.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + }, + "peerDependencies": { + "react": ">= 16", + "typescript": "^3.2.1 || ^4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/react-scripts/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==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-scripts/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-scripts/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -4005,20 +14626,91 @@ "react-dom": ">=16.6.0" } }, + "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==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "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==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recursive-readdir": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", + "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", + "dependencies": { + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", + "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, + "node_modules/regenerator-transform": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz", + "integrity": "sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==", + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regex-parser": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.2.11.tgz", + "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==" + }, "node_modules/regexp.prototype.flags": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", - "integrity": "sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==", - "dev": true, + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", + "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" + "define-properties": "^1.1.3", + "functions-have-names": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -4027,12 +14719,99 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/regexpu-core": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.1.tgz", + "integrity": "sha512-nCOzW2V/X15XpLsK2rlgdwrysrBq+AauCn+omItIz4R1pIcmeot5zvjdmOBRLzEH/CkC6IxMJVmxDe3QcMuNVQ==", "dependencies": { - "is-core-module": "^2.11.0", + "@babel/regjsgen": "^0.8.0", + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "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==", + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/resolve": { + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", + "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", + "dependencies": { + "is-core-module": "^2.9.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -4043,6 +14822,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/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==", + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4051,11 +14849,82 @@ "node": ">=4" } }, + "node_modules/resolve-url-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", + "integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==", + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^7.0.35", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=8.9" + }, + "peerDependencies": { + "rework": "1.0.1", + "rework-visit": "1.0.0" + }, + "peerDependenciesMeta": { + "rework": { + "optional": true + }, + "rework-visit": { + "optional": true + } + } + }, + "node_modules/resolve-url-loader/node_modules/picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==" + }, + "node_modules/resolve-url-loader/node_modules/postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dependencies": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + } + }, + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve.exports": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz", + "integrity": "sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "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" @@ -4065,7 +14934,6 @@ "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" }, @@ -4077,26 +14945,78 @@ } }, "node_modules/rollup": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.23.0.tgz", - "integrity": "sha512-h31UlwEi7FHihLe1zbk+3Q7z1k/84rb9BSwmBSr/XjOCEaBJ2YyedQDuM0t/kfOS0IxM+vk1/zI9XxYj9V+NJQ==", - "dev": true, + "version": "2.79.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", + "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=14.18.0", - "npm": ">=8.0.0" + "node": ">=10.0.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-terser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", + "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "jest-worker": "^26.2.1", + "serialize-javascript": "^4.0.0", + "terser": "^5.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0" + } + }, + "node_modules/rollup-plugin-terser/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup-plugin-terser/node_modules/jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/rollup-plugin-terser/node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/rollup-plugin-terser/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==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "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", @@ -4115,11 +15035,29 @@ "queue-microtask": "^1.2.2" } }, + "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==", + "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/safe-regex-test": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -4129,6 +15067,69 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sanitize.css": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", + "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==" + }, + "node_modules/sass-loader": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", + "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", + "dependencies": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -4137,20 +15138,189 @@ "loose-envify": "^1.1.0" } }, + "node_modules/schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==" + }, + "node_modules/selfsigned": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz", + "integrity": "sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==", + "dependencies": { + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, "bin": { "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, "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" }, @@ -4162,16 +15332,22 @@ "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.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.0.tgz", + "integrity": "sha512-QHsz8GgQIGKlRi24yFc6a6lN69Idnx634w49ay6+jA5yFh7a1UY+4Rp6HPx/L/1zcEDPEij8cIsiqR6bQsE5VQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, "dependencies": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -4181,6 +15357,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -4193,11 +15402,92 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/source-map-loader": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.2.tgz", + "integrity": "sha512-BokxPoLjyl3iOrgkWaakaxqnelAJSS+0V+De0kKIq6lyWrXuiPgYTGp6z3iHmqljKAaLXwZa+ctD8GccRJeVvg==", + "dependencies": { + "abab": "^2.0.5", + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.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==", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead" + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" + }, "node_modules/stack-generator": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", @@ -4206,6 +15496,25 @@ "stackframe": "^1.3.4" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "engines": { + "node": ">=8" + } + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -4238,11 +15547,18 @@ "stacktrace-gps": "^3.0.4" } }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dev": true, "dependencies": { "internal-slot": "^1.0.4" }, @@ -4250,11 +15566,53 @@ "node": ">= 0.4" } }, + "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==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-natural-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", + "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==" + }, + "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==", + "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/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==" + }, "node_modules/string.prototype.matchall": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", "integrity": "sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -4269,28 +15627,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string.prototype.trim": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz", - "integrity": "sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/string.prototype.trimend": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -4304,7 +15644,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -4314,11 +15653,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "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" }, @@ -4327,19 +15678,33 @@ } }, "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "engines": { - "node": ">=4" + "node": ">=8" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "engines": { + "node": ">=6" } }, "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" }, @@ -4347,10 +15712,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-loader": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", + "integrity": "sha512-GPcQ+LDJbrcxHORTRes6Jy2sfvK2kS6hpSfI/fXhPt+spVzxF6LJ1dHLN9zIGmVaaP044YKaIatFaufENRiDoQ==", + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/stylehacks": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", + "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, "node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.1.3.tgz", + "integrity": "sha512-GP6WDNWf+o403jrEp9c5jibKavrtLW+/qYGhFxFrG8maXhwTBI7gLLhiBb0o7uFccWN+EOS9aMO6cGHWAO07OA==" }, "node_modules/supports-color": { "version": "5.5.0", @@ -4363,6 +15758,37 @@ "node": ">=4" } }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/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==", + "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", @@ -4374,11 +15800,277 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" + }, + "node_modules/svgo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", + "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", + "deprecated": "This SVGO version is no longer supported. Upgrade to v2.x.x.", + "dependencies": { + "chalk": "^2.4.1", + "coa": "^2.0.2", + "css-select": "^2.0.0", + "css-select-base-adapter": "^0.1.1", + "css-tree": "1.0.0-alpha.37", + "csso": "^4.0.2", + "js-yaml": "^3.13.1", + "mkdirp": "~0.5.1", + "object.values": "^1.1.0", + "sax": "~1.2.4", + "stable": "^0.1.8", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/svgo/node_modules/css-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", + "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^3.2.1", + "domutils": "^1.7.0", + "nth-check": "^1.0.2" + } + }, + "node_modules/svgo/node_modules/css-what": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz", + "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/svgo/node_modules/domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/svgo/node_modules/domutils/node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" + }, + "node_modules/svgo/node_modules/nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "dependencies": { + "boolbase": "~1.0.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, + "node_modules/tailwindcss": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.6.tgz", + "integrity": "sha512-BfgQWZrtqowOQMC2bwaSNe7xcIjdDEgixWGYOd6AL0CbKHJlvhfdbINeAW76l1sO+1ov/MJ93ODJ9yluRituIw==", + "dependencies": { + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "color-name": "^1.1.4", + "detective": "^5.2.1", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.12", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "lilconfig": "^2.0.6", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.0.9", + "postcss-import": "^14.1.0", + "postcss-js": "^4.0.0", + "postcss-load-config": "^3.1.4", + "postcss-nested": "6.0.0", + "postcss-selector-parser": "^6.0.11", + "postcss-value-parser": "^4.2.0", + "quick-lru": "^5.1.1", + "resolve": "^1.22.1" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/tailwindcss/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==" + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.16.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.16.3.tgz", + "integrity": "sha512-v8wWLaS/xt3nE9dgKEWhNUFP6q4kngO5B8eYFUuebsu7Dw/UNAnpUod6UHo04jSSkv8TzKHjZDSd7EXdDQAl8Q==", + "dependencies": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz", + "integrity": "sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.14", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.0", + "terser": "^5.14.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, "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 + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + }, + "node_modules/throat": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", + "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==" }, "node_modules/throttle-debounce": { "version": "2.3.0", @@ -4388,6 +16080,16 @@ "node": ">=8" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -4396,19 +16098,64 @@ "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==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", + "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/tryer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", + "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==" + }, "node_modules/tsconfig-paths": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", - "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", - "dev": true, + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", + "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", "dependencies": { "@types/json5": "^0.0.29", - "json5": "^1.0.2", + "json5": "^1.0.1", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } @@ -4417,7 +16164,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, "dependencies": { "minimist": "^1.2.0" }, @@ -4425,11 +16171,42 @@ "json5": "lib/cli.js" } }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, "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" }, @@ -4437,11 +16214,18 @@ "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==", + "engines": { + "node": ">=4" + } + }, "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, + "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==", "engines": { "node": ">=10" }, @@ -4449,11 +16233,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "for-each": "^0.3.3", @@ -4463,11 +16258,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-bigints": "^1.0.2", @@ -4478,11 +16293,87 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", + "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==" + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, "node_modules/update-browserslist-db": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", - "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", - "dev": true, + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", + "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", "funding": [ { "type": "opencollective", @@ -4491,10 +16382,6 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" } ], "dependencies": { @@ -4502,7 +16389,7 @@ "picocolors": "^1.0.0" }, "bin": { - "update-browserslist-db": "cli.js" + "browserslist-lint": "cli.js" }, "peerDependencies": { "browserslist": ">= 4.21.0" @@ -4512,57 +16399,86 @@ "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/vite": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.8.tgz", - "integrity": "sha512-uYB8PwN7hbMrf4j1xzGDk/lqjsZvCDbt/JC5dyfxc19Pg8kRm14LinK/uq+HSLNswZEoKmweGdtpbnxRtrAXiQ==", - "dev": true, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "dependencies": { - "esbuild": "^0.17.5", - "postcss": "^8.4.23", - "rollup": "^3.21.0" + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "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==" + }, + "node_modules/util.promisify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", + "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "bin": { - "vite": "bin/vite.js" + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", + "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" }, "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@types/node": ">= 14", - "less": "*", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" } }, "node_modules/void-elements": { @@ -4573,10 +16489,419 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", + "dependencies": { + "browser-process-hrtime": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "dependencies": { + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, "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==" + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "engines": { + "node": ">=10.4" + } + }, + "node_modules/webpack": { + "version": "5.75.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz", + "integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==", + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^0.0.51", + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/wasm-edit": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.10.0", + "es-module-lexer": "^0.9.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.3", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", + "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/webpack-dev-middleware/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.11.1.tgz", + "integrity": "sha512-lILVz9tAUy1zGFwieuaQtYiadImb5M3d+H+L1zDYalYoDl0cksAB1UNyuE5MMWJrG6zR1tXkCP2fitl7yoUJiw==", + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.1", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.4.2" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-server/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/webpack-dev-server/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.1.tgz", + "integrity": "sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==", + "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/webpack-manifest-plugin": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz", + "integrity": "sha512-YXUAwxtfKIJIKkhg03MKuiFAD72PlrqCiwdwO4VEXdRO5V0ORCNwaOwAZawPZalCbmH9kBDmXnNeQOw+BIEiow==", + "dependencies": { + "tapable": "^2.0.0", + "webpack-sources": "^2.2.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "webpack": "^4.44.2 || ^5.47.0" + } + }, + "node_modules/webpack-manifest-plugin/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webpack-manifest-plugin/node_modules/webpack-sources": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", + "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", + "dependencies": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/@types/estree": { + "version": "0.0.51", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", + "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dependencies": { + "iconv-lite": "0.4.24" + } + }, + "node_modules/whatwg-encoding/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==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", + "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" + }, + "node_modules/whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==" }, "node_modules/whatwg-url": { "version": "5.0.0", @@ -4587,11 +16912,15 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/whatwg-url/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==" + }, "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" }, @@ -4606,7 +16935,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, "dependencies": { "is-bigint": "^1.0.1", "is-boolean-object": "^1.1.0", @@ -4622,7 +16950,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", - "dev": true, "dependencies": { "is-map": "^2.0.1", "is-set": "^2.0.1", @@ -4637,7 +16964,6 @@ "version": "1.1.9", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", - "dev": true, "dependencies": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", @@ -4657,22 +16983,416 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/workbox-background-sync": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz", + "integrity": "sha512-0r4INQZMyPky/lj4Ou98qxcThrETucOde+7mRGJl13MPJugQNKeZQOdIJe/1AchOP23cTqHcN/YVpD6r8E6I8g==", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "6.5.4" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.5.4.tgz", + "integrity": "sha512-I/lBERoH1u3zyBosnpPEtcAVe5lwykx9Yg1k6f8/BGEPGaMMgZrwVrqL1uA9QZ1NGGFoyE6t9i7lBjOlDhFEEw==", + "dependencies": { + "workbox-core": "6.5.4" + } + }, + "node_modules/workbox-build": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.5.4.tgz", + "integrity": "sha512-kgRevLXEYvUW9WS4XoziYqZ8Q9j/2ziJYEtTrjdz5/L/cTUa2XfyMP2i7c3p34lgqJ03+mTiz13SdFef2POwbA==", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.11.1", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^5.2.0", + "@rollup/plugin-node-resolve": "^11.2.1", + "@rollup/plugin-replace": "^2.4.1", + "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^7.1.6", + "lodash": "^4.17.20", + "pretty-bytes": "^5.3.0", + "rollup": "^2.43.1", + "rollup-plugin-terser": "^7.0.0", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "6.5.4", + "workbox-broadcast-update": "6.5.4", + "workbox-cacheable-response": "6.5.4", + "workbox-core": "6.5.4", + "workbox-expiration": "6.5.4", + "workbox-google-analytics": "6.5.4", + "workbox-navigation-preload": "6.5.4", + "workbox-precaching": "6.5.4", + "workbox-range-requests": "6.5.4", + "workbox-recipes": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4", + "workbox-streams": "6.5.4", + "workbox-sw": "6.5.4", + "workbox-window": "6.5.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", + "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "dependencies": { + "json-schema": "^0.4.0", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/workbox-build/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/workbox-build/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/workbox-build/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/workbox-build/node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workbox-build/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/workbox-build/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" + }, + "node_modules/workbox-build/node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.5.4.tgz", + "integrity": "sha512-DCR9uD0Fqj8oB2TSWQEm1hbFs/85hXXoayVwFKLVuIuxwJaihBsLsp4y7J9bvZbqtPJ1KlCkmYVGQKrBU4KAug==", + "dependencies": { + "workbox-core": "6.5.4" + } + }, + "node_modules/workbox-core": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.5.4.tgz", + "integrity": "sha512-OXYb+m9wZm8GrORlV2vBbE5EC1FKu71GGp0H4rjmxmF4/HLbMCoTFws87M3dFwgpmg0v00K++PImpNQ6J5NQ6Q==" + }, + "node_modules/workbox-expiration": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.5.4.tgz", + "integrity": "sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ==", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "6.5.4" + } + }, + "node_modules/workbox-google-analytics": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.5.4.tgz", + "integrity": "sha512-8AU1WuaXsD49249Wq0B2zn4a/vvFfHkpcFfqAFHNHwln3jK9QUYmzdkKXGIZl9wyKNP+RRX30vcgcyWMcZ9VAg==", + "dependencies": { + "workbox-background-sync": "6.5.4", + "workbox-core": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.5.4.tgz", + "integrity": "sha512-IIwf80eO3cr8h6XSQJF+Hxj26rg2RPFVUmJLUlM0+A2GzB4HFbQyKkrgD5y2d84g2IbJzP4B4j5dPBRzamHrng==", + "dependencies": { + "workbox-core": "6.5.4" + } + }, + "node_modules/workbox-precaching": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.5.4.tgz", + "integrity": "sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg==", + "dependencies": { + "workbox-core": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4" + } + }, + "node_modules/workbox-range-requests": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz", + "integrity": "sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg==", + "dependencies": { + "workbox-core": "6.5.4" + } + }, + "node_modules/workbox-recipes": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.5.4.tgz", + "integrity": "sha512-QZNO8Ez708NNwzLNEXTG4QYSKQ1ochzEtRLGaq+mr2PyoEIC1xFW7MrWxrONUxBFOByksds9Z4//lKAX8tHyUA==", + "dependencies": { + "workbox-cacheable-response": "6.5.4", + "workbox-core": "6.5.4", + "workbox-expiration": "6.5.4", + "workbox-precaching": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4" + } + }, + "node_modules/workbox-routing": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.5.4.tgz", + "integrity": "sha512-apQswLsbrrOsBUWtr9Lf80F+P1sHnQdYodRo32SjiByYi36IDyL2r7BH1lJtFX8fwNHDa1QOVY74WKLLS6o5Pg==", + "dependencies": { + "workbox-core": "6.5.4" + } + }, + "node_modules/workbox-strategies": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.5.4.tgz", + "integrity": "sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw==", + "dependencies": { + "workbox-core": "6.5.4" + } + }, + "node_modules/workbox-streams": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.5.4.tgz", + "integrity": "sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg==", + "dependencies": { + "workbox-core": "6.5.4", + "workbox-routing": "6.5.4" + } + }, + "node_modules/workbox-sw": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.5.4.tgz", + "integrity": "sha512-vo2RQo7DILVRoH5LjGqw3nphavEjK4Qk+FenXeUsknKn14eCNedHOXWbmnvP4ipKhlE35pvJ4yl4YYf6YsJArA==" + }, + "node_modules/workbox-webpack-plugin": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-webpack-plugin/-/workbox-webpack-plugin-6.5.4.tgz", + "integrity": "sha512-LmWm/zoaahe0EGmMTrSLUi+BjyR3cdGEfU3fS6PN1zKFYbqAKuQ+Oy/27e4VSXsyIwAw8+QDfk1XHNGtZu9nQg==", + "dependencies": { + "fast-json-stable-stringify": "^2.1.0", + "pretty-bytes": "^5.4.1", + "upath": "^1.2.0", + "webpack-sources": "^1.4.3", + "workbox-build": "6.5.4" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": "^4.4.0 || ^5.9.0" + } + }, + "node_modules/workbox-webpack-plugin/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workbox-webpack-plugin/node_modules/webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dependencies": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + }, + "node_modules/workbox-window": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.5.4.tgz", + "integrity": "sha512-HnLZJDwYBE+hpG25AQBO8RUWBJRaCsI9ksQJEp3aCOFCaG5kqaToAYXFRAHxzRluM2cQbGzdQF5rjKPWPA1fug==", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "6.5.4" + } + }, + "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==", + "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/wrap-ansi/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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/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==" + }, "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 + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "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==", + "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": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "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 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { "version": "1.10.2", @@ -4682,11 +17402,35 @@ "node": ">= 6" } }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "engines": { + "node": ">=10" + } + }, "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" }, diff --git a/web/package.json b/web/package.json index c00e8c6..9e919ef 100644 --- a/web/package.json +++ b/web/package.json @@ -3,16 +3,14 @@ "version": "1.0.0", "private": true, "scripts": { - "start": "NODE_OPTIONS=\"--enable-source-maps\" vite", - "build": "vite build", - "serve": "vite preview", - "format": "prettier . --write", - "format:check": "prettier . --check", - "lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/" + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" }, "dependencies": { - "@emotion/react": "^11.11.0", - "@emotion/styled": "^11.11.0", + "@emotion/react": "^11.8.2", + "@emotion/styled": "^11.8.1", "@mui/icons-material": "^5.4.2", "@mui/material": "latest", "dexie": "^3.2.1", @@ -27,21 +25,10 @@ "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-router-dom": "^6.2.2", + "react-scripts": "^5.0.0", "stacktrace-gps": "^3.0.4", "stacktrace-js": "^2.0.2" }, - "devDependencies": { - "@vitejs/plugin-react": "^4.0.0", - "eslint": "^8.41.0", - "eslint-config-airbnb": "^19.0.4", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.32.2", - "eslint-plugin-react-hooks": "^4.6.0", - "prettier": "^2.8.8", - "vite": "^4.3.8" - }, "browserslist": { "production": [ ">0.2%", @@ -53,8 +40,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "prettier": { - "printWidth": 140 } } diff --git a/web/public/config.js b/web/public/config.js index 2f46d65..e714a4f 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -6,14 +6,11 @@ // During web development, you may change values here for rapid testing. var config = { - base_url: window.location.origin, // Change to test against a different server - app_root: "/app", - enable_login: true, - enable_signup: true, - enable_payments: false, - enable_reservations: true, - enable_emails: true, - enable_calls: true, - billing_contact: "", - disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"], + base_url: window.location.origin, // Set this to "https://127.0.0.1" to test against a different server + app_root: "/app", + enable_login: true, + enable_signup: true, + enable_payments: true, + enable_reservations: true, + disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] }; diff --git a/web/public/home.html b/web/public/home.html new file mode 100644 index 0000000..43007ca --- /dev/null +++ b/web/public/home.html @@ -0,0 +1,182 @@ + + + + + + ntfy.sh | Send push notifications to your phone via PUT/POST + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Send push notifications to your phone or desktop via PUT/POST

+

+ ntfy (pronounce: notify) is a simple HTTP-based pub-sub notification service. + It allows you to send notifications to your phone or desktop via scripts from any computer, + entirely without signup, cost or setup. It's also open source if you want to run your own. +

+
+ + + + + + + +
+ +

Publishing messages

+

+ Publishing messages can be done via PUT or POST. Topics are created on the fly by subscribing or publishing to them. + Because there is no sign-up, the topic is essentially a password, so pick something that's not easily guessable. +

+

+ Here's an example showing how to publish a message using a POST request (via curl -d): +

+ + curl -d "Backup successful 😀" ntfy.sh/mytopic + +

+ There are more features related to publishing messages: You can set a + notification priority, a title, + and tag messages. + Here's an example using some of them together: +

+ + curl \
+   -H "Title: Unauthorized access detected" \
+   -H "Priority: urgent" \
+   -H "Tags: warning,skull" \
+   -d "Remote access to $(hostname) detected. Act right away." \
+   ntfy.sh/mytopic +
+

+ Here's what that looks like in the Android app: +

+
+ +
Urgent notification with pop-over
+
+ +

Subscribe to a topic

+

+ You can create and subscribe to a topic either using your phone, + in this web UI, or in your own app by subscribing via the API. +

+ +

Subscribe from your phone

+

+ Simply get the app and start publishing messages. To learn more about the app, + check out the documentation. +

+

+ + + +

+

+ Here's a video showing the app in action: +

+
+ +
Sending push notifications to your Android phone
+
+ +

Subscribe via web app

+

+ Subscribe to topics in the web app and receive messages as desktop notification. + It is available at ntfy.sh/app. +

+
+ +
ntfy web app, available at ntfy.sh/app
+
+ +

Subscribe using the API

+

+ There's a super simple API that you can use to integrate your own app. You can consume + a JSON stream, + an SSE/EventSource stream, + a plain text stream, + or via WebSockets. +

+

+ Here's an example for JSON. The connection stays open, so you can retrieve messages as they come in: +

+ + $ curl -s ntfy.sh/mytopic/json
+ {"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"}
+ {"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Hi!"}
+ {"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"}
+ ... +
+

+ Here's a short video demonstrating it in action: +

+
+ +
Subscribing to the JSON stream with curl
+
+ +

Check out the docs!

+

+ ntfy has so many more features and you can learn about all of them in the documentation + (I tried my very best to make it the best docs ever 😉, not sure if I succeeded, hehe). +

+
+ +
Check out the documentation
+
+ +

100% open source & forever free

+

+ I love free software, and I'm doing this because it's fun. I have no bad intentions, and I will + never monetize or sell your information. This service will always stay + free and open. + You can read more in the FAQs and in the privacy policy. +

+ +
Made with ❤️ by Philipp C. Heckel
+
+ + + + diff --git a/web/public/index.html b/web/public/index.html new file mode 100644 index 0000000..4dd8ef2 --- /dev/null +++ b/web/public/index.html @@ -0,0 +1,44 @@ + + + + + ntfy web + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/web/public/static/css/app.css b/web/public/static/css/app.css index 213859c..12b105a 100644 --- a/web/public/static/css/app.css +++ b/web/public/static/css/app.css @@ -1,11 +1,10 @@ /* web app styling overrides */ -a, -a:visited { - color: #338574; +a, a:visited { + color: #338574; } a:hover { - text-decoration: none; - color: #317f6f; + text-decoration: none; + color: #317f6f; } diff --git a/web/public/static/css/fonts.css b/web/public/static/css/fonts.css index 2cf00a3..d14bad0 100644 --- a/web/public/static/css/fonts.css +++ b/web/public/static/css/fonts.css @@ -2,32 +2,40 @@ /* roboto-300 - latin */ @font-face { - font-family: "Roboto"; - font-style: normal; - font-weight: 300; - src: local(""), url("../fonts/roboto-v29-latin-300.woff2") format("woff2"); + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + src: local(''), + url('../fonts/roboto-v29-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('../fonts/roboto-v29-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* roboto-regular - latin */ @font-face { - font-family: "Roboto"; - font-style: normal; - font-weight: 400; - src: local(""), url("../fonts/roboto-v29-latin-regular.woff2") format("woff2"); + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: local(''), + url('../fonts/roboto-v29-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('../fonts/roboto-v29-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* roboto-500 - latin */ @font-face { - font-family: "Roboto"; - font-style: normal; - font-weight: 500; - src: local(""), url("../fonts/roboto-v29-latin-500.woff2") format("woff2"); + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: local(''), + url('../fonts/roboto-v29-latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('../fonts/roboto-v29-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* roboto-700 - latin */ @font-face { - font-family: "Roboto"; - font-style: normal; - font-weight: 700; - src: local(""), url("../fonts/roboto-v29-latin-700.woff2") format("woff2"); + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + src: local(''), + url('../fonts/roboto-v29-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('../fonts/roboto-v29-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } diff --git a/web/public/static/css/home.css b/web/public/static/css/home.css new file mode 100644 index 0000000..feeaa7e --- /dev/null +++ b/web/public/static/css/home.css @@ -0,0 +1,280 @@ +/* general styling */ + +html, body { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 1.1em; + color: #444; + margin: 0; + padding: 0; +} + +html { + /* prevent scrollbar from repositioning website: + * https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */ + overflow-y: scroll; +} + +a, a:visited { + color: #338574; +} + +a:hover { + text-decoration: none; + color: #317f6f; +} + +h1 { + margin-top: 35px; + margin-bottom: 30px; + font-size: 2.5em; + word-wrap: break-word; /* For very long topics */ + padding-right: 40px; /* For the X on the detail page */ + font-weight: 300; + color: #666; +} + +h2 { + margin-top: 30px; + margin-bottom: 5px; + font-size: 1.8em; + font-weight: 300; + color: #333; +} + +h3 { + margin-top: 25px; + margin-bottom: 5px; + font-size: 1.3em; + font-weight: 300; + color: #333; +} + +p { + margin-top: 10px; + margin-bottom: 20px; + line-height: 160%; + font-weight: 400; +} + +p.smallMarginBottom { + margin-bottom: 10px; +} + +b { + font-weight: 500; +} + +tt { + background: #eee; + padding: 2px 7px; + border-radius: 3px; +} + +code { + display: block; + background: #eee; + font-family: monospace; + padding: 20px; + border-radius: 3px; + margin-top: 10px; + margin-bottom: 20px; + overflow-x: auto; + white-space: nowrap; +} + +/* Main page */ + +#main { + max-width: 900px; + margin: 0 auto 50px auto; + padding: 0 10px; +} + +#error { + color: darkred; + font-style: italic; +} + +#ironicCenterTagDontFreakOut { + color: #666; +} + +/* Anchors */ + +.anchor .anchorLink { + color: #ccc; + text-decoration: none; + padding: 0 5px; + visibility: hidden; +} + +.anchor:hover .anchorLink { + visibility: visible; +} + +.anchor .anchorLink:hover { + color: #338574; + visibility: visible; +} + +/* Figures */ + +figure { + text-align: center; +} + +figure img, figure video { + filter: drop-shadow(3px 3px 3px #ccc); + border-radius: 7px; + max-width: 100%; +} + +figure video { + width: 100%; + max-height: 450px; +} + +figcaption { + text-align: center; + font-style: italic; + padding-top: 10px; +} + +/* Screenshots */ + +#screenshots { + text-align: center; +} + +#screenshots img { + height: 190px; + margin: 3px; + border-radius: 5px; + filter: drop-shadow(2px 2px 2px #ddd); +} + +#screenshots .nowrap { + white-space: nowrap; +} + +/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */ + +.lightbox { + opacity: 0; + visibility: hidden; + position: fixed; + left:0; + right: 0; + top: 0; + bottom: 0; + z-index: -1; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease-in; +} + +.lightbox.show { + background-color: rgba(0,0,0, 0.75); + opacity: 1; + visibility: visible; + z-index: 1000; +} + +.lightbox img { + max-width: 90%; + max-height: 90%; + filter: drop-shadow(5px 5px 10px #222); + border-radius: 5px; +} + +.lightbox .close-lightbox { + cursor: pointer; + position: absolute; + top: 30px; + right: 30px; + width: 20px; + height: 20px; +} + +.lightbox .close-lightbox::after, +.lightbox .close-lightbox::before { + content: ''; + width: 3px; + height: 20px; + background-color: #ddd; + position: absolute; + border-radius: 5px; + transform: rotate(45deg); +} + +.lightbox .close-lightbox::before { + transform: rotate(-45deg); +} + +.lightbox .close-lightbox:hover::after, +.lightbox .close-lightbox:hover::before { + background-color: #fff; +} + +/* Header */ + +#header { + background: #338574; + height: 130px; +} + +#header #headerBox { + max-width: 900px; + margin: 0 auto; + padding: 0 10px; +} + +#header #logo { + margin-top: 23px; + float: left; +} + +#header #name { + float: left; + color: white; + font-size: 2.6em; + font-weight: 300; + margin: 35px 0 0 20px; +} + +#header ol { + list-style-type: none; + float: right; + margin-top: 80px; +} + +#header ol li { + display: inline-block; + margin: 0 10px; + font-weight: 400; +} + +#header ol li a, nav ol li a:visited { + color: white; + text-decoration: none; +} + +#header ol li a:hover { + text-decoration: underline; +} + +li { + padding: 4px 0; + margin: 4px 0; + font-size: 0.9em; +} + + +/* Hide top menu SMALL SCREEN */ +@media only screen and (max-width: 780px) { + #header ol { + display: none; + } +} diff --git a/web/public/static/fonts/roboto-v29-latin-300.woff b/web/public/static/fonts/roboto-v29-latin-300.woff new file mode 100644 index 0000000..5565042 Binary files /dev/null and b/web/public/static/fonts/roboto-v29-latin-300.woff differ diff --git a/web/public/static/fonts/roboto-v29-latin-500.woff b/web/public/static/fonts/roboto-v29-latin-500.woff new file mode 100644 index 0000000..c9eb5ca Binary files /dev/null and b/web/public/static/fonts/roboto-v29-latin-500.woff differ diff --git a/web/public/static/fonts/roboto-v29-latin-700.woff b/web/public/static/fonts/roboto-v29-latin-700.woff new file mode 100644 index 0000000..a5d98fc Binary files /dev/null and b/web/public/static/fonts/roboto-v29-latin-700.woff differ diff --git a/web/public/static/fonts/roboto-v29-latin-regular.woff b/web/public/static/fonts/roboto-v29-latin-regular.woff new file mode 100644 index 0000000..86b3863 Binary files /dev/null and b/web/public/static/fonts/roboto-v29-latin-regular.woff differ diff --git a/web/public/static/images/favicon.ico b/web/public/static/images/favicon.ico deleted file mode 100644 index 857fa54..0000000 Binary files a/web/public/static/images/favicon.ico and /dev/null differ diff --git a/web/public/static/img/android-video-overview.mp4 b/web/public/static/img/android-video-overview.mp4 new file mode 100644 index 0000000..cf29509 Binary files /dev/null and b/web/public/static/img/android-video-overview.mp4 differ diff --git a/web/public/static/img/android-video-subscribe-api.mp4 b/web/public/static/img/android-video-subscribe-api.mp4 new file mode 100644 index 0000000..d73e5c6 Binary files /dev/null and b/web/public/static/img/android-video-subscribe-api.mp4 differ diff --git a/web/public/static/img/badge-appstore.png b/web/public/static/img/badge-appstore.png new file mode 100644 index 0000000..0b4ce1c Binary files /dev/null and b/web/public/static/img/badge-appstore.png differ diff --git a/web/public/static/img/badge-fdroid.png b/web/public/static/img/badge-fdroid.png new file mode 100644 index 0000000..9464d38 Binary files /dev/null and b/web/public/static/img/badge-fdroid.png differ diff --git a/web/public/static/img/badge-googleplay.png b/web/public/static/img/badge-googleplay.png new file mode 100644 index 0000000..36036d8 Binary files /dev/null and b/web/public/static/img/badge-googleplay.png differ diff --git a/web/public/static/img/favicon.png b/web/public/static/img/favicon.png new file mode 100644 index 0000000..92312fe Binary files /dev/null and b/web/public/static/img/favicon.png differ diff --git a/web/public/static/images/ntfy.png b/web/public/static/img/ntfy.png similarity index 100% rename from web/public/static/images/ntfy.png rename to web/public/static/img/ntfy.png diff --git a/.github/images/screenshot-curl.png b/web/public/static/img/screenshot-curl.png similarity index 100% rename from .github/images/screenshot-curl.png rename to web/public/static/img/screenshot-curl.png diff --git a/web/public/static/img/screenshot-docs.png b/web/public/static/img/screenshot-docs.png new file mode 100644 index 0000000..4345ded Binary files /dev/null and b/web/public/static/img/screenshot-docs.png differ diff --git a/web/public/static/img/screenshot-phone-add.jpg b/web/public/static/img/screenshot-phone-add.jpg new file mode 100644 index 0000000..f728ec9 Binary files /dev/null and b/web/public/static/img/screenshot-phone-add.jpg differ diff --git a/.github/images/screenshot-phone-detail.jpg b/web/public/static/img/screenshot-phone-detail.jpg similarity index 100% rename from .github/images/screenshot-phone-detail.jpg rename to web/public/static/img/screenshot-phone-detail.jpg diff --git a/.github/images/screenshot-phone-main.jpg b/web/public/static/img/screenshot-phone-main.jpg similarity index 100% rename from .github/images/screenshot-phone-main.jpg rename to web/public/static/img/screenshot-phone-main.jpg diff --git a/.github/images/screenshot-phone-notification.jpg b/web/public/static/img/screenshot-phone-notification.jpg similarity index 100% rename from .github/images/screenshot-phone-notification.jpg rename to web/public/static/img/screenshot-phone-notification.jpg diff --git a/web/public/static/img/screenshot-phone-popover.png b/web/public/static/img/screenshot-phone-popover.png new file mode 100644 index 0000000..31d1515 Binary files /dev/null and b/web/public/static/img/screenshot-phone-popover.png differ diff --git a/.github/images/screenshot-web-detail.png b/web/public/static/img/screenshot-web-detail.png similarity index 100% rename from .github/images/screenshot-web-detail.png rename to web/public/static/img/screenshot-web-detail.png diff --git a/web/public/static/js/home.js b/web/public/static/js/home.js new file mode 100644 index 0000000..80b1405 --- /dev/null +++ b/web/public/static/js/home.js @@ -0,0 +1,84 @@ + +/* All the things */ + +let currentUrl = window.location.hostname; +if (window.location.port) { + currentUrl += ':' + window.location.port +} + +/* Screenshots */ +const lightbox = document.getElementById("lightbox"); + +const showScreenshotOverlay = (e, el, index) => { + lightbox.classList.add('show'); + document.addEventListener('keydown', nextScreenshotKeyboardListener); + return showScreenshot(e, index); +}; + +const showScreenshot = (e, index) => { + const actualIndex = resolveScreenshotIndex(index); + lightbox.innerHTML = '
' + screenshots[actualIndex].innerHTML; + lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e,actualIndex+1); }; + currentScreenshotIndex = actualIndex; + e.stopPropagation(); + return false; +}; + +const nextScreenshot = (e) => { + return showScreenshot(e, currentScreenshotIndex+1); +}; + +const previousScreenshot = (e) => { + return showScreenshot(e, currentScreenshotIndex-1); +}; + +const resolveScreenshotIndex = (index) => { + if (index < 0) { + return screenshots.length - 1; + } else if (index > screenshots.length - 1) { + return 0; + } + return index; +}; + +const hideScreenshotOverlay = (e) => { + lightbox.classList.remove('show'); + document.removeEventListener('keydown', nextScreenshotKeyboardListener); +}; + +const nextScreenshotKeyboardListener = (e) => { + switch (e.keyCode) { + case 37: + previousScreenshot(e); + break; + case 39: + nextScreenshot(e); + break; + } +}; + +let currentScreenshotIndex = 0; +const screenshots = [...document.querySelectorAll("#screenshots a")]; +screenshots.forEach((el, index) => { + el.onclick = (e) => { return showScreenshotOverlay(e, el, index); }; +}); + +lightbox.onclick = hideScreenshotOverlay; + +// Add anchor links +document.querySelectorAll('.anchor').forEach((el) => { + if (el.hasAttribute('id')) { + const id = el.getAttribute('id'); + const anchor = document.createElement('a'); + anchor.innerHTML = `#`; + el.appendChild(anchor); + } +}); + +// Change ntfy.sh url and protocol to match self-hosted one +document.querySelectorAll('.ntfyUrl').forEach((el) => { + el.innerHTML = currentUrl; +}); +document.querySelectorAll('.ntfyProtocol').forEach((el) => { + el.innerHTML = window.location.protocol + "//"; +}); diff --git a/web/public/static/langs/ar.json b/web/public/static/langs/ar.json index 0c9fcc7..3e0d155 100644 --- a/web/public/static/langs/ar.json +++ b/web/public/static/langs/ar.json @@ -39,295 +39,7 @@ "message_bar_type_message": "اكتب رسالة هنا", "alert_not_supported_title": "الإشعارات غير مدعومة", "alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.", - "message_bar_error_publishing": "خطأ خلال نشر الإشعار", + "message_bar_error_publishing": "خطأ أثناء نشر الإشعار", "notifications_delete": "حذف", - "notifications_copied_to_clipboard": "تم نسخه إلى الحافظة", - "action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات", - "action_bar_toggle_action_menu": "فتح/إغلاق قائمة الإجراءات", - "alert_grant_button": "امنح الآن", - "notifications_attachment_open_button": "فتح المرفق", - "notifications_attachment_copy_url_title": "نسخ عنوان URL للمرفق إلى الحافظة", - "notifications_click_copy_url_title": "انسخ رابط URL إلى الحافظة", - "notifications_none_for_topic_title": "لم تتلق بعد أية إشعارات حول هذا الموضوع.", - "notifications_none_for_any_title": "لم تتلق أية إشعارات.", - "notifications_no_subscriptions_title": "يبدو أنك لا تملك أي اشتراكات بعد.", - "notifications_example": "مثال", - "notifications_loading": "تحميل الإشعارات…", - "publish_dialog_title_topic": "أنشُر إلى {{topic}}", - "publish_dialog_title_no_topic": "انشُر الإشعار", - "publish_dialog_emoji_picker_show": "اختر رمزًا تعبيريًا", - "publish_dialog_priority_min": "أولوية دنيا", - "publish_dialog_priority_low": "أولوية منخفضة", - "publish_dialog_priority_default": "الأولوية الافتراضية", - "publish_dialog_priority_high": "أولوية عالية", - "publish_dialog_base_url_label": "الرابط التشعبي للخدمة", - "publish_dialog_priority_max": "أولوية قصوى", - "publish_dialog_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts", - "publish_dialog_title_label": "العنوان", - "publish_dialog_title_placeholder": "عنوان الإشعار، على سبيل المثال تنبيه مساحة القرص", - "publish_dialog_message_label": "الرسالة", - "publish_dialog_message_placeholder": "اكتب رسالة هنا", - "publish_dialog_tags_label": "الوسوم", - "publish_dialog_priority_label": "الأولوية", - "publish_dialog_click_placeholder": "العنوان التشعبي URL الذي يتم فتحه عند النقر فوق الإشعار", - "publish_dialog_email_label": "البريد الإلكتروني", - "publish_dialog_filename_label": "اسم الملف", - "publish_dialog_attach_label": "الرابط التشعبي URL للمرفق", - "publish_dialog_filename_placeholder": "اسم ملف المرفق", - "publish_dialog_delay_label": "تأخير", - "publish_dialog_delay_reset": "إزالة تأخر التسليم", - "publish_dialog_chip_click_label": "انقر على عنوان URL", - "publish_dialog_chip_email_label": "إعادة التوجيه إلى البريد الإلكتروني", - "publish_dialog_chip_attach_file_label": "إرفاق ملف محلي", - "publish_dialog_chip_topic_label": "تغيير الموضوع", - "publish_dialog_button_cancel_sending": "إلغاء الإرسال", - "publish_dialog_button_send": "أرسل", - "publish_dialog_checkbox_publish_another": "نشر آخر", - "publish_dialog_attached_file_title": "الملف المرفق:", - "publish_dialog_attached_file_filename_placeholder": "اسم الملف المرفق", - "publish_dialog_attached_file_remove": "إزالة الملف المرفق", - "publish_dialog_drop_file_here": "قم بإسقاط ملف هنا", - "emoji_picker_search_placeholder": "البحث عن رمز تعبيري", - "emoji_picker_search_clear": "مسح البحث", - "subscribe_dialog_subscribe_title": "الإشتراك في الموضوع", - "subscribe_dialog_subscribe_use_another_label": "استخدام خادم آخر", - "subscribe_dialog_subscribe_base_url_label": "الرابط التشعبي URL للخدمة", - "subscribe_dialog_subscribe_button_subscribe": "اشترِك", - "subscribe_dialog_login_title": "تسجيل الدخول مطلوب", - "subscribe_dialog_login_username_label": "اسم المستخدم، على سبيل المثال phil", - "subscribe_dialog_login_password_label": "كلمة المرور", - "subscribe_dialog_login_button_login": "الولوج", - "subscribe_dialog_error_user_anonymous": "مجهول", - "prefs_notifications_title": "الإشعارات", - "prefs_notifications_sound_title": "صوت الإشعار", - "prefs_notifications_sound_no_sound": "لا صوت", - "prefs_notifications_min_priority_description_any": "عرض جميع الإشعارات، بغض النظر عن الأولوية", - "prefs_notifications_delete_after_title": "حذف الإشعارات", - "prefs_notifications_delete_after_never": "أبداً", - "prefs_notifications_delete_after_three_hours": "بعد ثلاث ساعات", - "prefs_notifications_delete_after_one_day": "بعد يوم واحد", - "prefs_notifications_delete_after_one_month": "بعد شهر واحد", - "prefs_notifications_delete_after_never_description": "لا يتم حذف الإشعارات تلقائيا مطلقا", - "prefs_notifications_delete_after_one_week_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد", - "prefs_notifications_delete_after_one_month_description": "يتم حذف الإشعارات تلقائيا بعد شهر واحد", - "prefs_users_table": "قائمة المستخدمين", - "prefs_users_edit_button": "تعديل المستخدم", - "prefs_users_table_user_header": "المستخدم", - "prefs_users_table_base_url_header": "الرابط التشعبي للخدمة", - "priority_default": "افتراضية", - "prefs_users_dialog_username_label": "اسم المستخدم، على سبيل المثال phil", - "prefs_users_dialog_button_cancel": "إلغاء", - "prefs_users_dialog_button_add": "اضافة", - "prefs_users_dialog_button_save": "حفظ", - "prefs_appearance_title": "المظهر", - "prefs_appearance_language_title": "اللغة", - "error_boundary_gathering_info": "جمع مزيد من المعلومات …", - "error_boundary_unsupported_indexeddb_title": "التصفح الخاص غير مدعوم", - "priority_high": "عالية", - "priority_max": "قصوى", - "error_boundary_title": "أوه لا ، لقد تحطم ntfy", - "prefs_users_delete_button": "حذف المستخدم", - "prefs_users_add_button": "إضافة مستخدم", - "prefs_notifications_min_priority_any": "مهما كانت الأولوية", - "prefs_notifications_delete_after_one_week": "بعد أسبوع واحد", - "prefs_notifications_delete_after_three_hours_description": "يتم حذف الإشعارات تلقائيا بعد ثلاث ساعات", - "prefs_notifications_delete_after_one_day_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد", - "prefs_users_title": "إدارة المستخدمين", - "prefs_users_dialog_title_add": "إضافة مستخدم", - "prefs_users_dialog_title_edit": "تعديل المستخدم", - "prefs_users_dialog_base_url_label": "عنوان URL للخدمة، على سبيل المثال، https://ntfy.sh", - "publish_dialog_button_cancel": "إلغاء", - "publish_dialog_message_published": "تم نشر الإشعار", - "prefs_users_dialog_password_label": "كلمة المرور", - "publish_dialog_base_url_placeholder": "عنوان URL للخدمة، على سبيل المثال، https://example.com", - "publish_dialog_progress_uploading": "جارٍ التحميل…", - "publish_dialog_topic_label": "اسم الموضوع", - "publish_dialog_topic_reset": "إعادة تعيين الموضوع", - "publish_dialog_email_reset": "إزالة إعادة توجيه البريد الإلكتروني", - "publish_dialog_email_placeholder": "عنوان لإعادة توجيه الإشعار إليه، على سبيل المثال phil@example.com", - "publish_dialog_other_features": "ميزات أخرى:", - "publish_dialog_chip_attach_url_label": "إرفاق ملف عن طريق عنوان URL", - "subscribe_dialog_subscribe_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts", - "prefs_notifications_sound_description_none": "لا تصدر الإشعارات أي صوت عند وصولها", - "publish_dialog_chip_delay_label": "تأخير التسليم", - "subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.", - "subscribe_dialog_subscribe_button_cancel": "إلغاء", - "common_back": "العودة", - "prefs_notifications_sound_play": "تشغيل الصوت المحدد", - "prefs_notifications_min_priority_title": "أولوية دنيا", - "prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط", - "notifications_no_subscriptions_description": "انقر فوق الرابط \"{{linktext}}\" لإنشاء موضوع أو الاشتراك فيه. بعد ذلك، يمكنك إرسال رسائل عبر PUT أو POST وستتلقى إشعارات هنا.", - "publish_dialog_click_label": "الرابط التشعبي URL للنقر", - "publish_dialog_tags_placeholder": "قائمة علامات مفصولة بفواصل، على سبيل المثال تحذير, srv1-backup", - "publish_dialog_attach_placeholder": "إرفاق ملف بعنوان URL ، على سبيل المثال https://f-droid.org/F-Droid.apk", - "publish_dialog_attach_reset": "إزالة عنوان URL للمرفق", - "subscribe_dialog_error_user_not_authorized": "المستخدم {{username}} غير مصرح به", - "common_save": "حفظ", - "common_add": "إضافة", - "signup_form_username": "إسم المستخدم", - "signup_form_confirm_password": "تأكيد كلمة المرور", - "login_title": "تسجيل الدخول إلى حسابك ntfy", - "login_form_button_submit": "الولوج", - "login_link_signup": "إنشاء حساب", - "login_disabled": "تم تعطيل تسجيل الدخول", - "action_bar_account": "الحساب", - "action_bar_change_display_name": "تغيير الإسم المعروض", - "signup_error_creation_limit_reached": "تم بلوغ حد إنشاء الحسابات", - "action_bar_reservation_add": "حجز الموضوع", - "action_bar_reservation_edit": "تغيير الحجز", - "action_bar_profile_title": "الملف التعريفي", - "action_bar_profile_settings": "اﻹعدادات", - "action_bar_profile_logout": "الخروج", - "action_bar_sign_in": "الولوج", - "action_bar_sign_up": "إنشاء حساب", - "nav_button_account": "الحساب", - "nav_upgrade_banner_label": "قم بالترقية إلى NTFY Pro", - "reserve_dialog_checkbox_label": "حجز الموضوع وإعداد الوصول", - "subscribe_dialog_subscribe_button_generate_topic_name": "توليد إسم", - "subscribe_dialog_error_topic_already_reserved": "الموضوع محجوز بالفعل", - "account_basics_title": "الحساب", - "account_basics_username_title": "إسم المستخدم", - "account_basics_username_description": "مرحبًا، هذا أنت ❤", - "account_basics_username_admin_tooltip": "أنت مدير", - "account_basics_password_title": "كلمة المرور", - "account_basics_password_description": "غيّر كلمة مرور حسابك", - "account_basics_password_dialog_title": "تغيير كلمة المرور", - "account_basics_password_dialog_current_password_label": "كلمة المرور الحالية", - "account_basics_password_dialog_new_password_label": "كلمة المرور الجديدة", - "account_basics_password_dialog_confirm_password_label": "تأكيد كلمة المرور", - "account_basics_password_dialog_button_submit": "تغيير كلمة المرور", - "account_basics_password_dialog_current_password_incorrect": "الكلمة السرية خاطئة", - "account_usage_title": "الإستخدام", - "account_usage_of_limit": "من {{limit}}", - "account_usage_unlimited": "غير محدود", - "account_basics_tier_title": "نوع الحساب", - "account_basics_tier_description": "مستوى قوة حسابك", - "account_basics_tier_admin": "مدير", - "account_basics_tier_free": "مجاني", - "account_basics_tier_upgrade_button": "الترقية إلى Pro", - "account_basics_tier_change_button": "تغيير", - "account_basics_tier_manage_billing_button": "إدارة الفوترة", - "account_usage_messages_title": "الرسائل المنشورة", - "account_usage_reservations_title": "المواضيع المحجوزة", - "account_usage_attachment_storage_title": "تخزين المرفقات", - "account_delete_title": "حذف الحساب", - "account_delete_description": "احذف حسابك نهائيا", - "account_delete_dialog_label": "كلمة المرور", - "account_upgrade_dialog_title": "تغيير فئة الحساب", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} رسائل يومية", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} من رسائل البريد الإلكتروني اليومية", - "account_upgrade_dialog_button_cancel": "إلغاء", - "account_upgrade_dialog_button_pay_now": "ادفع الآن واشترك", - "account_upgrade_dialog_button_cancel_subscription": "إلغاء الاشتراك", - "account_tokens_title": "رموز الوصول", - "account_tokens_table_token_header": "الرمز المميز", - "account_tokens_table_last_access_header": "آخر وصول", - "account_tokens_table_expires_header": "تنتهي مدة صلاحيته في", - "account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا", - "account_tokens_table_current_session": "جلسة المتصفح الحالية", - "common_copy_to_clipboard": "انسخ إلى الحافظة", - "account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية", - "account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول", - "account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث", - "account_tokens_dialog_title_create": "إنشاء رمز مميز للوصول", - "account_tokens_dialog_title_edit": "تعديل الرمز المميز للوصول", - "account_tokens_dialog_title_delete": "حذف الرمز المميز للوصول", - "account_tokens_dialog_label": "التسمية، على سبيل المثال إشعارات الرادار", - "account_tokens_dialog_button_create": "إنشاء رمز مميز", - "account_tokens_dialog_button_update": "تحديث الرمز المميز", - "account_tokens_dialog_button_cancel": "إلغاء", - "account_tokens_dialog_expires_label": "تنتهي صلاحية الرمز المميز للوصول في", - "account_tokens_dialog_expires_unchanged": "اترك تاريخ انتهاء الصلاحية دون تغيير", - "account_tokens_dialog_expires_x_hours": "تنتهي صلاحية الرمز المميز في {{hours}} ساعات", - "account_tokens_dialog_expires_never": "لا تنتهي صلاحية الرمز المميز أبدًا", - "account_tokens_delete_dialog_title": "حذف الرمز المميز للوصول", - "account_tokens_delete_dialog_submit_button": "حذف الرمز المميز نهائيا", - "prefs_users_table_cannot_delete_or_edit": "لا يمكن حذف أو تحرير المستخدم الذي قام بتسجيل الدخول", - "prefs_reservations_add_button": "إضافة موضوع محجوز", - "prefs_reservations_table": "جدول المواضيع المحجوزة", - "prefs_reservations_table_topic_header": "الموضوع", - "prefs_reservations_table_access_header": "الوصول", - "prefs_reservations_table_everyone_deny_all": "أنا فقط من يستطيع النشر والاشتراك", - "prefs_reservations_table_everyone_write_only": "يمكنني النشر والاشتراك ، ويمكن للجميع النشر", - "prefs_reservations_table_everyone_read_write": "يمكن للجميع النشر والاشتراك", - "prefs_reservations_table_not_subscribed": "غير مشترك", - "prefs_reservations_dialog_title_edit": "تحرير الموضوع المحجوز", - "prefs_reservations_dialog_topic_label": "الموضوع", - "prefs_reservations_dialog_access_label": "الوصول", - "reservation_delete_dialog_action_delete_title": "حذف الرسائل والمرفقات المخزنة مؤقتا", - "reservation_delete_dialog_submit_button": "حذف الحجز", - "signup_title": "إنشاء حساب ntfy", - "common_cancel": "إلغاء", - "signup_form_password": "كلمة المرور", - "signup_already_have_account": "هل لديك حساب؟ قم بتسجيل الدخول!", - "signup_form_button_submit": "إنشاء حساب", - "signup_disabled": "تم تعطيل التسجيل", - "display_name_dialog_placeholder": "الإسم المعروض", - "display_name_dialog_title": "تغيير الإسم المعروض", - "account_basics_tier_basic": "أساسي", - "account_usage_emails_title": "رسائل البريد الإلكتروني المرسلة", - "account_usage_reservations_none": "لا توجد مواضيع محجوزة لهذا الحساب", - "account_usage_cannot_create_portal_session": "تعذر فتح بوابة الفوترة", - "account_delete_dialog_button_cancel": "إلغاء", - "account_delete_dialog_button_submit": "حذف الحساب نهائيا", - "account_upgrade_dialog_button_update_subscription": "تحديث الاشتراك", - "account_tokens_table_copied_to_clipboard": "تم نسخ الرمز المميز للوصول", - "prefs_reservations_title": "المواضيع المحجوزة", - "prefs_reservations_table_everyone_read_only": "يمكنني النشر والاشتراك ، ويمكن للجميع الاشتراك", - "prefs_reservations_table_click_to_subscribe": "انقر للاشتراك", - "reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا", - "action_bar_reservation_delete": "إزالة الحجز", - "display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر.", - "prefs_users_description": "إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة المرور في التخزين المحلي للمتصفح.", - "notifications_more_details": "لمزيد من المعلومات، الرجاء الاطّلاع على موقع الويب أو على الدليل.", - "publish_dialog_details_examples_description": "للحصول على أمثلة ووصف مُفصّل لجميع ميزات الإرسال، يرجى الاستناد إلى الدليل.", - "subscribe_dialog_subscribe_description": "قد لا تكون الموضوعات محمية بكلمة سر لذا اختر اسمًا ليس من السهل تخمينه وبمجرد اشتراكك، يمكنك الحصول على إشعارات عبر \"PUT/POST\".", - "prefs_notifications_sound_description_some": "تقوم الإشعارات بتشغيل صوت {{sound}} عند وصولها", - "notifications_none_for_topic_description": "لإرسال إشعارات إلى هذا الموضوع، ما عليك سوى PUT أو POST إلى عنوان URL الخاص بالموضوع.", - "priority_low": "منخفضة", - "signup_form_toggle_password_visibility": "تبديل رؤية كلمة المرور", - "account_usage_limits_reset_daily": "يعاد تحديد حدود الاستخدام يوميا في منتصف الليل (UTC)", - "account_tokens_table_label_header": "المُلصَقة", - "account_upgrade_dialog_button_redirect_signup": "تسجيل فوري", - "account_upgrade_dialog_tier_current_label": "الحالي", - "account_tokens_dialog_expires_x_days": "تنتهي صلاحية الرمز المميز في غضون {{days}} أيام", - "prefs_reservations_dialog_title_add": "حجز موضوع", - "prefs_reservations_description": "يمكنك حجز أسماء الموضوعات للاستخدام الشخصي هنا. يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات الوصول للمستخدمين الآخرين إلى الموضوع.", - "prefs_users_description_no_sync": "لا تتم مزامنة المستخدمين وكلمات المرور مع حسابك.", - "reservation_delete_dialog_action_delete_description": "سيتم حذف الرسائل والمرفقات المخزنة مؤقتا نهائيا. لا يمكن التراجع عن هذا الإجراء.", - "notifications_actions_http_request_title": "إرسال طلب HTTP {{method}} إلى {{url}}", - "notifications_none_for_any_description": "لإرسال إشعارات إلى موضوع ما، ما عليك سوى إرسال طلب PUT أو POST إلى الرابط التشعبي URL للموضوع. إليك مثال باستخدام أحد مواضيعك.", - "error_boundary_description": "من الواضح أن هذا لا ينبغي أن يحدث. آسف جدًا بشأن هذا.
إن كان لديك دقيقة، يرجى الإبلاغ عن ذلك على GitHub ، أو إعلامنا عبر Discord أو Matrix .", - "nav_button_muted": "الإشعارات المكتومة", - "priority_min": "دنيا", - "signup_error_username_taken": "تم حجز اسم المستخدم {{username}} مِن قَبلُ", - "action_bar_reservation_limit_reached": "بلغت الحد الأقصى", - "prefs_reservations_delete_button": "إعادة تعيين الوصول إلى الموضوع", - "prefs_reservations_edit_button": "تعديل الوصول إلى موضوع", - "prefs_reservations_limit_reached": "لقد بلغت الحد الأقصى من المواضيع المحجوزة.", - "reservation_delete_dialog_action_keep_description": "ستصبح الرسائل والمرفقات المخزنة مؤقتًا على الخادم مرئية للعموم وللأشخاص الذين لديهم معرفة باسم الموضوع.", - "reservation_delete_dialog_description": "تؤدي إزالة الحجز إلى التخلي عن ملكية الموضوع، مما يسمح للآخرين بحجزه. يمكنك الاحتفاظ بالرسائل والمرفقات الموجودة أو حذفها.", - "prefs_reservations_dialog_description": "يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات وصول المستخدمين الآخرين إليه.", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "توفير ما يصل إلى {{discount}}٪", - "account_upgrade_dialog_interval_monthly": "شهريا", - "account_upgrade_dialog_tier_features_attachment_total_size": "إجمالي مساحة التخزين {{totalsize}}", - "publish_dialog_progress_uploading_detail": "تحميل {{loaded}}/{{total}} ({{percent}}٪) …", - "account_basics_tier_interval_monthly": "شهريا", - "account_basics_tier_interval_yearly": "سنويا", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} مواضيع محجوزة", - "account_upgrade_dialog_billing_contact_website": "للأسئلة المتعلقة بالفوترة، يرجى الرجوع إلى موقعنا على الويب.", - "prefs_notifications_min_priority_description_x_or_higher": "إظهار الإشعارات إذا كانت الأولوية {{number}} ({{name}}) أو أعلى", - "account_upgrade_dialog_billing_contact_email": "للأسئلة المتعلقة بالفوترة، الرجاء الاتصال بنا مباشرة.", - "account_upgrade_dialog_tier_selected_label": "المحدد", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} لكل ملف", - "account_upgrade_dialog_interval_yearly": "سنويا", - "account_upgrade_dialog_tier_features_no_reservations": "لا توجد مواضيع محجوزة", - "account_upgrade_dialog_interval_yearly_discount_save": "وفر {{discount}}٪", - "publish_dialog_click_reset": "إزالة الرابط التشعبي URL للنقر", - "prefs_notifications_min_priority_description_max": "إظهار الإشعارات إذا كانت الأولوية 5 (كحد أقصى)", - "publish_dialog_attachment_limits_file_reached": "يتجاوز الحد الأقصى للملف {{fileSizeLimit}}", - "publish_dialog_attachment_limits_quota_reached": "يتجاوز الحصة، {{remainingBytes}} متبقية", - "account_basics_tier_paid_until": "تم دفع مبلغ الاشتراك إلى غاية {{date}}، وسيتم تجديده تِلْقائيًا", - "account_basics_tier_canceled_subscription": "تم إلغاء اشتراكك وسيتم إعادته إلى مستوى حساب مجاني بداية مِن {{date}}.", - "account_delete_dialog_billing_warning": "إلغاء حسابك أيضاً يلغي اشتراكك في الفوترة فوراً ولن تتمكن من الوصول إلى لوح الفوترة بعد الآن." + "notifications_copied_to_clipboard": "تم نسخه إلى الحافظة" } diff --git a/web/public/static/langs/bg.json b/web/public/static/langs/bg.json index a040b01..2c1f0b1 100644 --- a/web/public/static/langs/bg.json +++ b/web/public/static/langs/bg.json @@ -104,7 +104,7 @@ "subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts", "subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър", "subscribe_dialog_login_username_label": "Потребител, напр. phil", - "common_back": "Назад", + "subscribe_dialog_login_button_back": "Назад", "subscribe_dialog_subscribe_button_cancel": "Отказ", "subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.", "subscribe_dialog_subscribe_button_subscribe": "Абониране", @@ -187,105 +187,5 @@ "prefs_users_table": "Таблица с потребители", "prefs_users_edit_button": "Промяна на потребител", "error_boundary_unsupported_indexeddb_title": "Поверително разглеждане не се поддържа", - "error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.

Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по проблема в GitHub или да се свържете с нас в Discord или Matrix.", - "signup_title": "Създаване на профил в ntfy", - "signup_form_username": "Потребител", - "signup_form_password": "Парола", - "signup_form_button_submit": "Регистриране", - "signup_form_toggle_password_visibility": "Превключване видимостта на паролата", - "signup_already_have_account": "Имате профил? Впишете се!", - "signup_error_username_taken": "Потребителското име {{username}} е заето", - "login_title": "Впишете се в профила си в ntfy", - "login_form_button_submit": "Вписване", - "login_link_signup": "Регистриране", - "login_disabled": "Вписването е изключено", - "action_bar_account": "Профил", - "action_bar_change_display_name": "Промяна на показваното име", - "action_bar_reservation_add": "Резервиране на тема", - "action_bar_reservation_delete": "Премахване на резервацията", - "action_bar_reservation_limit_reached": "Ограничението е достигнато", - "action_bar_profile_title": "Профил", - "action_bar_profile_settings": "Настройки", - "action_bar_profile_logout": "Изход", - "action_bar_sign_in": "Вписване", - "nav_button_account": "Профил", - "nav_upgrade_banner_label": "Надграждане до ntfy Pro", - "signup_form_confirm_password": "Парола отново", - "signup_disabled": "Регистрациите са затворени", - "signup_error_creation_limit_reached": "Достигнатео е ограничението за създаване на профили", - "display_name_dialog_title": "Промяна на показваното име", - "action_bar_reservation_edit": "Промяна на резервацията", - "action_bar_sign_up": "Регистриране", - "account_basics_title": "Профил", - "alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на Notifications API.", - "display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.", - "subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана", - "nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и имейли и по-големи прикачени файлове", - "display_name_dialog_placeholder": "Наименование", - "reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп", - "subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име", - "account_basics_username_title": "Потребител", - "account_basics_username_description": "Хей, това сте вие ❤", - "account_basics_username_admin_tooltip": "Вие сте администратор", - "account_basics_password_title": "Парола", - "account_delete_dialog_label": "Парола", - "account_basics_password_dialog_title": "Смяна на парола", - "account_basics_password_dialog_current_password_label": "Текуща парола", - "account_basics_password_dialog_new_password_label": "Нова парола", - "account_basics_password_dialog_confirm_password_label": "Парола отново", - "account_basics_password_dialog_button_submit": "Смяна на парола", - "account_usage_title": "Употреба", - "account_usage_of_limit": "от {{limit}}", - "account_usage_unlimited": "Неограничено", - "account_usage_limits_reset_daily": "Ограниченията се нулират всеки ден в полунощ (UTC)", - "account_basics_tier_interval_monthly": "месечно", - "account_basics_tier_interval_yearly": "годишно", - "account_basics_password_description": "Промяна на паролата на профила", - "account_basics_tier_title": "Вид на профила", - "account_basics_tier_admin": "Администратор", - "account_basics_tier_admin_suffix_with_tier": "(с {{tier}} ниво)", - "account_basics_tier_admin_suffix_no_tier": "(без ниво)", - "account_basics_tier_free": "безплатен", - "account_basics_tier_basic": "базов", - "account_basics_tier_change_button": "Променяне", - "account_basics_tier_paid_until": "Абонаментът е платен до {{date}} и автоматично ще се поднови", - "account_usage_attachment_storage_title": "Хранилище за прикачени файлове", - "account_delete_dialog_button_cancel": "Отказ", - "account_upgrade_dialog_interval_monthly": "Месечно", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} резервирани теми", - "account_upgrade_dialog_tier_features_no_reservations": "Няма резервирани теми", - "account_tokens_dialog_button_cancel": "Отказ", - "account_delete_title": "Премахване на профила", - "account_upgrade_dialog_title": "Промяна нивото на профила", - "account_usage_emails_title": "Изпратени съобщения", - "account_usage_reservations_title": "Резервирани теми", - "account_usage_reservations_none": "Няма резервирани теми", - "account_usage_cannot_create_portal_session": "Порталът за разплащане не може да бъде отворен", - "account_upgrade_dialog_interval_yearly": "Годишно", - "account_delete_description": "Безвъзвратно премахване на профила", - "account_delete_dialog_button_submit": "Безвъзвратно премахване на профила", - "account_upgrade_dialog_interval_yearly_discount_save": "отстъпка {{discount}}%", - "account_upgrade_dialog_button_cancel": "Отказ", - "account_upgrade_dialog_button_redirect_signup": "Регистриране", - "account_tokens_table_label_header": "Етикет", - "prefs_reservations_edit_button": "Настройки на достъпа", - "prefs_reservations_table_topic_header": "Тема", - "prefs_reservations_table_access_header": "Достъп", - "prefs_reservations_dialog_topic_label": "Тема", - "prefs_reservations_dialog_access_label": "Достъп", - "account_basics_password_dialog_current_password_incorrect": "Грешна парола", - "account_basics_tier_description": "Ниво на профила", - "account_basics_tier_upgrade_button": "Надграждане до Pro", - "account_usage_messages_title": "Публикувани съобщения", - "account_tokens_table_last_access_header": "Последен достъп", - "account_basics_tier_payment_overdue": "Имате просрочено задължение. Обновете начина на плащане, защото в противен случай скоро профилът ви ще загуби предимствата на абонамента.", - "account_usage_basis_ip_description": "Статистиката и ограниченията на използване се отчитат по IP адрес, така че може да бъдат споделени с други потребители. Показаните по-горе ограничения са приблизителни и се основават на съществуващите ограничения на използване.", - "account_delete_dialog_description": "Това действие ще доведе до безвъзвратното изтриване на профила ви, включително на всички данни, които се съхраняват на сървъра. След изтриването потребителското ви име няма да бъде достъпно в продължение на 7 дни. Ако наистина искате да продължите, потвърдете с паролата си в полето по-долу.", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} резервирана тема", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "спестете до {{discount}}%", - "account_delete_dialog_billing_warning": "Изтриването на профила незабавно отменя и платения абонамент. Няма да имате достъп до таблото за плащания.", - "account_upgrade_dialog_cancel_warning": "Това действие ще прекрати абонамента и ще промени профила ви на неплатен на {{date}}. На тази дата резервираните теми, както и пазените на сървъра съобщения, ще бъдат премахнати.", - "account_upgrade_dialog_proration_info": "Преизчисляване на плащания: При надграждане между платени планове разликата в цената ще бъде начислена незабавно. При преминаване към по-евтин план надплатената сума ще бъде използвана за плащане за бъдещи периоди.", - "account_basics_tier_manage_billing_button": "Управление на плащанията", - "account_basics_tier_canceled_subscription": "Абонаментът е прекратен и профилът ще бъде променен на неплатен на {{date}}." + "error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.

Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по проблема в GitHub или да се свържете с нас в Discord или Matrix." } diff --git a/web/public/static/langs/cs.json b/web/public/static/langs/cs.json index 6b967c8..c2ba220 100644 --- a/web/public/static/langs/cs.json +++ b/web/public/static/langs/cs.json @@ -91,7 +91,7 @@ "subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr", "subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil", "subscribe_dialog_login_password_label": "Heslo", - "common_back": "Zpět", + "subscribe_dialog_login_button_back": "Zpět", "subscribe_dialog_login_button_login": "Přihlásit se", "subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován", "subscribe_dialog_error_user_anonymous": "anonymně", @@ -187,183 +187,5 @@ "prefs_notifications_sound_play": "Přehrát vybraný zvuk", "prefs_users_table": "Tabulka uživatelů", "notifications_attachment_file_document": "jiný dokument", - "publish_dialog_delay_reset": "Odebrat odložené doručení", - "signup_form_confirm_password": "Potvrdit heslo", - "signup_form_button_submit": "Zaregistrovat se", - "signup_form_username": "Uživatelské jméno", - "signup_form_toggle_password_visibility": "Přepnout viditelnost hesla", - "signup_already_have_account": "Už máte účet? Přihlašte se!", - "signup_error_username_taken": "Uživatelské jméno {{username}} je již obsazeno", - "signup_error_creation_limit_reached": "Dosažen limit pro vytvoření účtu", - "login_title": "Přihlaste se do svého ntfy účtu", - "login_form_button_submit": "Přihlásit se", - "login_link_signup": "Zaregistrovat se", - "login_disabled": "Přihlašování je zakázáno", - "action_bar_account": "Účet", - "action_bar_reservation_add": "Rezervovat téma", - "action_bar_reservation_edit": "Změnit rezervaci", - "action_bar_reservation_delete": "Odstranit rezervaci", - "action_bar_reservation_limit_reached": "Limit dosažen", - "action_bar_profile_title": "Profil", - "action_bar_profile_settings": "Nastavení", - "action_bar_profile_logout": "Odhlásit se", - "action_bar_sign_up": "Zaregistrovat se", - "nav_button_account": "Účet", - "nav_upgrade_banner_label": "Upgradovat na nfty Pro", - "nav_upgrade_banner_description": "Rezervace témat, více zpráv a emailů a větší přílohy", - "signup_title": "Vytvořit nfty účet", - "signup_form_password": "Heslo", - "display_name_dialog_description": "Nastaví alternativní název pro téma, které se zobrazí v seznamu odběrů. Toto pomáhá jednodušeji identifikovat témata s komplikovanými jmény.", - "action_bar_change_display_name": "Změnit zobrazovaný název", - "action_bar_sign_in": "Přihlásit se", - "alert_not_supported_context_description": "Oznámení jsou podporována pouze přes HTTPS. Toto je limitace Notifications API.", - "display_name_dialog_title": "Změnit zobrazovaný název", - "account_basics_password_title": "Heslo", - "account_basics_password_dialog_title": "Změna hesla", - "subscribe_dialog_error_topic_already_reserved": "Téma již rezervováno", - "subscribe_dialog_subscribe_button_generate_topic_name": "Generovat název", - "account_delete_dialog_description": "Dojde k trvalému odstranění vašeho účtu včetně všech dat uložených na serveru. Po smazání bude vaše uživatelské jméno po dobu 7 dnů nedostupné. Pokud opravdu chcete pokračovat, potvrďte prosím své heslo.", - "account_basics_tier_admin_suffix_with_tier": "(s úrovní {{tier}})", - "account_basics_tier_admin": "Administrátor", - "account_basics_tier_basic": "Základní", - "account_basics_tier_free": "Zdarma", - "account_basics_tier_admin_suffix_no_tier": "(žádná úroveň)", - "account_basics_tier_upgrade_button": "Přejít na verzi Pro", - "account_upgrade_dialog_cancel_warning": "Vaše předplatné se tímto zruší a váš účet se k datu {{date}} degraduje na nižší úroveň. K tomuto datu budou smazány rezervace témat i zprávy uložené v mezipaměti serveru.", - "account_upgrade_dialog_reservations_warning_other": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Před změnou úrovně odstraňte alespoň {{počet}} rezervací. Rezervace můžete odstranit v Nastavení.", - "reservation_delete_dialog_description": "Odstraněním rezervace se vzdáte vlastnictví tématu a umožníte ostatním, aby si ho rezervovali. Stávající zprávy a přílohy si můžete ponechat nebo je odstranit.", - "account_tokens_description": "Při publikování a odběru prostřednictvím rozhraní ntfy API používejte přístupové tokeny, abyste nemuseli odesílat přihlašovací údaje k účtu. Více informací najdete v dokumentaci.", - "account_tokens_table_copied_to_clipboard": "Přístupový token zkopírován", - "account_tokens_table_last_origin_tooltip": "Z IP adresy {{ip}}, klikněte pro vyhledání", - "account_tokens_dialog_button_cancel": "Zrušit", - "account_tokens_dialog_expires_never": "Token nikdy nevyprší", - "account_tokens_delete_dialog_description": "Před odstraněním přístupového tokenu se ujistěte, že jej aktivně nepoužívají žádné aplikace ani skripty. Tuto akci nelze vrátit zpět.", - "prefs_users_description_no_sync": "Uživatelé a hesla nejsou synchronizováni s vaším účtem.", - "prefs_users_table_cannot_delete_or_edit": "Nelze odstranit ani upravit přihlášeného uživatele", - "prefs_reservations_title": "Rezervovaná témata", - "prefs_reservations_description": "Zde si můžete rezervovat názvy témat pro osobní použití. Rezervací tématu získáte vlastnické právo k tématu a můžete definovat přístupová práva pro ostatní uživatele k tématu.", - "prefs_reservations_table_click_to_subscribe": "Kliknutím se přihlásíte k odběru", - "prefs_reservations_dialog_description": "Rezervací tématu získáte vlastnictví tématu a můžete definovat přístupová oprávnění pro ostatní uživatele.", - "prefs_reservations_dialog_access_label": "Přístup", - "reservation_delete_dialog_action_keep_title": "Zachovat zprávy a přílohy v mezipaměti", - "signup_disabled": "Přihlášení je zakázáno", - "display_name_dialog_placeholder": "Zobrazovaný název", - "reserve_dialog_checkbox_label": "Rezervace tématu a nastavení přístupu", - "account_basics_title": "Účet", - "account_basics_username_title": "Uživatelské jméno", - "account_basics_username_description": "Hej, to jsi ty ❤", - "account_basics_username_admin_tooltip": "Jste správce", - "account_basics_password_description": "Změna hesla k účtu", - "account_basics_password_dialog_current_password_label": "Současné heslo", - "account_basics_password_dialog_new_password_label": "Nové heslo", - "account_basics_password_dialog_confirm_password_label": "Potvrzení hesla", - "account_basics_password_dialog_button_submit": "Změnit heslo", - "account_basics_password_dialog_current_password_incorrect": "Nesprávné heslo", - "account_usage_title": "Použití", - "account_usage_of_limit": "z {{limit}}", - "account_usage_unlimited": "Neomezeně", - "account_usage_limits_reset_daily": "Limity používání se resetují denně o půlnoci (UTC)", - "account_basics_tier_title": "Typ účtu", - "account_basics_tier_description": "Úroveň oprávnění vašeho účtu", - "account_basics_tier_change_button": "Změnit", - "account_basics_tier_paid_until": "Předplatné zaplaceno do {{date}} a bude automaticky obnoveno", - "account_basics_tier_payment_overdue": "Vaše platba je po splatnosti. Aktualizujte prosím svůj způsob platby, jinak bude váš účet brzy degradován.", - "account_basics_tier_canceled_subscription": "Vaše předplatné bylo zrušeno a ke dni {{date}} bude převedeno na bezplatný účet.", - "account_basics_tier_manage_billing_button": "Správa vyúčtování", - "account_usage_messages_title": "Zveřejněné zprávy", - "account_usage_emails_title": "Odeslané e-maily", - "account_usage_reservations_title": "Rezervovaná témata", - "account_usage_reservations_none": "Žádná rezervovaná témata pro tento účet", - "account_usage_attachment_storage_title": "Úložiště příloh", - "account_usage_attachment_storage_description": "{{filesize}} na soubor, maže se po {{expiry}}", - "account_usage_basis_ip_description": "Statistiky a limity používání tohoto účtu jsou založeny na vaší IP adrese, takže mohou být sdíleny s ostatními uživateli. Výše uvedené limity jsou přibližné a vycházejí ze stávajících limitů.", - "account_usage_cannot_create_portal_session": "Nelze otevřít portál pro fakturaci", - "account_delete_title": "Odstranit účet", - "account_delete_description": "Trvale odstranit účet", - "account_delete_dialog_label": "Heslo", - "account_delete_dialog_button_cancel": "Zrušit", - "account_delete_dialog_button_submit": "Trvale odstranit účet", - "account_delete_dialog_billing_warning": "Odstraněním účtu se také okamžitě zruší vaše předplatné. Nebudete již mít přístup k fakturačnímu panelu.", - "account_upgrade_dialog_title": "Změna úrovně účtu", - "account_upgrade_dialog_proration_info": "Prohlášení: Při přechodu mezi placenými úrovněmi bude rozdíl v ceně zaúčtován okamžitě. Při přechodu na nižší úroveň se zůstatek použije na platbu za budoucí zúčtovací období.", - "account_upgrade_dialog_reservations_warning_one": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Než změníte svou úroveň, odstraňte alespoň jednu rezervaci. Rezervace můžete odstranit v Nastavení.", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} rezervovaných témat", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} denních zpráv", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} denních e-mailů", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na soubor", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} celkový úložný prostor", - "account_upgrade_dialog_tier_selected_label": "Vybráno", - "account_upgrade_dialog_tier_current_label": "Současné", - "account_upgrade_dialog_button_cancel": "Zrušit", - "account_upgrade_dialog_button_redirect_signup": "Zaregistrovat se nyní", - "account_upgrade_dialog_button_pay_now": "Zaplatit a předplatit si", - "account_upgrade_dialog_button_cancel_subscription": "Zrušit předplatné", - "account_upgrade_dialog_button_update_subscription": "Aktualizovat předplatné", - "account_tokens_title": "Přístupové tokeny", - "account_tokens_table_token_header": "Token", - "account_tokens_table_last_access_header": "Poslední přístup", - "account_tokens_table_expires_header": "Vyprší", - "account_tokens_table_never_expires": "Nikdy nevyprší", - "account_tokens_table_current_session": "Současná relace prohlížeče", - "common_copy_to_clipboard": "Kopírování do schránky", - "account_tokens_table_label_header": "Popisek", - "account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace", - "account_tokens_table_create_token_button": "Vytvořit přístupový token", - "account_tokens_dialog_title_create": "Vytvoření přístupového tokenu", - "account_tokens_dialog_title_edit": "Úprava přístupového tokenu", - "account_tokens_dialog_title_delete": "Odstranění přístupového tokenu", - "account_tokens_dialog_label": "Popisek, např. Radarr notifications", - "account_tokens_dialog_button_create": "Vytvořit token", - "account_tokens_dialog_button_update": "Aktualizovat token", - "account_tokens_dialog_expires_label": "Platnost přístupového tokenu vyprší za", - "account_tokens_dialog_expires_unchanged": "Ponechat datum vypršení platnosti beze změny", - "account_tokens_dialog_expires_x_hours": "Token vyprší za {{hours}} hodin", - "account_tokens_dialog_expires_x_days": "Token vyprší za {{days}} dní", - "account_tokens_delete_dialog_title": "Odstranění přístupového tokenu", - "account_tokens_delete_dialog_submit_button": "Trvale odstranit token", - "prefs_reservations_limit_reached": "Dosáhli jste limitu rezervovaných témat.", - "prefs_reservations_add_button": "Přidat rezervované téma", - "prefs_reservations_edit_button": "Upravit přístup k tématu", - "prefs_reservations_delete_button": "Resetovat přístup k tématu", - "prefs_reservations_table": "Tabulka rezervovaných témat", - "prefs_reservations_table_topic_header": "Téma", - "prefs_reservations_table_access_header": "Přístup", - "prefs_reservations_table_everyone_deny_all": "Pouze já mohu publikovat a přihlásit se k odběru", - "prefs_reservations_table_everyone_read_only": "Mohu publikovat a přihlásit se k odběru, kdokoli se může přihlásit k odběru", - "prefs_reservations_table_everyone_write_only": "Mohu publikovat a přihlásit se k odběru, kdokoli může publikovat", - "prefs_reservations_table_everyone_read_write": "Kdokoli může publikovat a přihlásit se k odběru", - "prefs_reservations_table_not_subscribed": "Odběr není přihlášen", - "prefs_reservations_dialog_title_add": "Rezervovat téma", - "prefs_reservations_dialog_title_edit": "Úprava rezervovaného tématu", - "prefs_reservations_dialog_title_delete": "Odstranění rezervovaného tématu", - "prefs_reservations_dialog_topic_label": "Téma", - "reservation_delete_dialog_action_keep_description": "Zprávy a přílohy, které jsou uloženy v mezipaměti serveru, se stanou veřejně viditelnými pro osoby, které znají název tématu.", - "reservation_delete_dialog_action_delete_title": "Odstranění zpráv a příloh uložených v mezipaměti", - "reservation_delete_dialog_action_delete_description": "Zprávy a přílohy uložené v mezipaměti budou trvale odstraněny. Tuto akci nelze vrátit zpět.", - "reservation_delete_dialog_submit_button": "Odstranit rezervaci", - "account_basics_tier_interval_yearly": "roční", - "account_upgrade_dialog_interval_yearly_discount_save": "ušetříte {{discount}}%", - "account_upgrade_dialog_tier_price_per_month": "měsíc", - "account_upgrade_dialog_tier_features_no_reservations": "Žádná rezervovaná témata", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "ušetříte až {{discount}}%", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} účtováno ročně. Ušetříte {{save}}.", - "account_basics_tier_interval_monthly": "měsíční", - "account_upgrade_dialog_interval_monthly": "Měsíční", - "account_upgrade_dialog_interval_yearly": "Roční", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} za rok. Účtuje se měsíčně.", - "account_upgrade_dialog_billing_contact_email": "V případě dotazů týkajících se fakturace nás prosím kontaktujte přímo.", - "account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich webových stránkách.", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervované téma", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} denní zpráva", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail", - "publish_dialog_call_label": "Telefonát", - "publish_dialog_call_reset": "Odstranit telefonát", - "publish_dialog_chip_call_label": "Telefonát", - "account_basics_phone_numbers_title": "Telefonní čísla", - "account_basics_phone_numbers_dialog_description": "Pro oznámení prostřednictvím tel. hovoru, musíte přidat a ověřit alespoň jedno telefonní číslo. Ověření lze provést pomocí SMS nebo telefonátu.", - "account_basics_phone_numbers_description": "K oznámení telefonátem", - "account_basics_phone_numbers_no_phone_numbers_yet": "Zatím žádná telefonní čísla", - "account_basics_phone_numbers_copied_to_clipboard": "Telefonní číslo zkopírováno do schránky", - "publish_dialog_chip_call_no_verified_numbers_tooltip": "Žádná ověřená telefonní čísla", - "publish_dialog_call_item": "Vytočit číslo {{number}}" + "publish_dialog_delay_reset": "Odebrat odložené doručení" } diff --git a/web/public/static/langs/cy.json b/web/public/static/langs/cy.json deleted file mode 100644 index 68846b8..0000000 --- a/web/public/static/langs/cy.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "notifications_delete": "Dileu", - "action_bar_sign_in": "Mewngofnodi", - "notifications_copied_to_clipboard": "Wedi'i gopio i'r clipfwrdd", - "common_cancel": "Canslo", - "nav_button_account": "Cyfrif", - "common_save": "Arbed", - "common_add": "Ychwanegu", - "signup_title": "Creu cyfrif ntfy", - "signup_form_username": "Enw defnyddiwr", - "signup_form_password": "Cyfrinair", - "action_bar_logo_alt": "logo ntfy", - "action_bar_settings": "Gosodiadau", - "action_bar_profile_title": "Proffil", - "action_bar_profile_logout": "Allgofnodi", - "message_bar_publish": "Cyhoeddi neges", - "notifications_attachment_copy_url_button": "Copio URL", - "notifications_attachment_open_title": "Ewch i {{url}}", - "publish_dialog_base_url_label": "URL y Gwasanaeth", - "publish_dialog_priority_high": "Blaenoriaeth uchel", - "publish_dialog_title_label": "Teitl", - "publish_dialog_message_label": "Neges", - "publish_dialog_attach_label": "URL Atodiad", - "publish_dialog_filename_label": "Enw ffeil", - "publish_dialog_filename_placeholder": "Enw ffeil yr atodiad", - "action_bar_account": "Cyfrif", - "action_bar_unsubscribe": "Dad-danysgrifio", - "login_title": "Mewngofnodi i'ch cyfrif ntfy", - "login_form_button_submit": "Mewngofnodi", - "action_bar_change_display_name": "Newid enw arddangos", - "action_bar_profile_settings": "Gosodiadau", - "nav_button_settings": "Gosodiadau", - "nav_button_documentation": "Dogfennaeth", - "alert_not_supported_context_description": "Dim ond dros HTTPS y gellir derbyn cyhoeddiadau. Mae hyn yn gyfyngiad ar yr API Notifications.", - "notifications_attachment_open_button": "Agor atodiad", - "notifications_attachment_file_document": "dogfen arall", - "notifications_click_open_button": "Agor linc", - "publish_dialog_base_url_placeholder": "URL y Gwasanaeth, e.e. https://example.com", - "publish_dialog_attach_placeholder": "Atodi ffeil drwy URL, e.e. https://f-droid.org/F-Droid.apk", - "notifications_click_copy_url_button": "Copio linc", - "notifications_actions_open_url_title": "Ewch i {{url}}", - "publish_dialog_email_label": "Ebost" -} diff --git a/web/public/static/langs/da.json b/web/public/static/langs/da.json deleted file mode 100644 index c7477df..0000000 --- a/web/public/static/langs/da.json +++ /dev/null @@ -1,283 +0,0 @@ -{ - "common_save": "Gem", - "common_add": "Tilføj", - "signup_title": "Opret en ntfy konto", - "signup_form_username": "Brugernavn", - "signup_form_password": "Kodeord", - "signup_form_confirm_password": "Bekræft kodeord", - "common_cancel": "Annuller", - "action_bar_account": "Konto", - "signup_error_username_taken": "Brugernavnet {{username}} er optaget", - "login_form_button_submit": "Log ind", - "action_bar_show_menu": "Vis menu", - "action_bar_logo_alt": "ntfy logo", - "action_bar_settings": "Indstillinger", - "signup_form_button_submit": "Opret konto", - "signup_form_toggle_password_visibility": "Skift synlighed af adgangskode", - "signup_disabled": "Tilmelding er deaktiveret", - "signup_error_creation_limit_reached": "Grænsen for kontooprettelse er nået", - "login_title": "Log ind på din ntfy konto", - "login_link_signup": "Opret konto", - "login_disabled": "Login er deaktiveret", - "action_bar_reservation_add": "Reserver emne", - "action_bar_reservation_edit": "Rediger reservation", - "action_bar_reservation_delete": "Fjern reservation", - "action_bar_reservation_limit_reached": "Grænsen er nået", - "action_bar_send_test_notification": "Send test notifikation", - "action_bar_unsubscribe": "Afmeld", - "action_bar_toggle_mute": "Slå lyden fra/til for notifikationer", - "action_bar_change_display_name": "Skift visningsnavn", - "action_bar_toggle_action_menu": "Åben/luk handlings menu", - "action_bar_profile_title": "Profil", - "action_bar_profile_settings": "Indstillinger", - "action_bar_profile_logout": "Log ud", - "action_bar_sign_in": "Log ind", - "action_bar_sign_up": "Opret konto", - "message_bar_type_message": "Skriv en besked her", - "nav_button_settings": "Indstillinger", - "message_bar_publish": "Offentliggør besked", - "nav_topics_title": "Tilmeldte emner", - "nav_button_all_notifications": "Alle notifikationer", - "nav_button_connecting": "forbinder", - "nav_upgrade_banner_label": "Opgrader til ntfy Pro", - "alert_grant_title": "Notifikationer er deaktiveret", - "alert_grant_description": "Giv din browser tilladelse til at vise skrivebordsnotifikationer.", - "alert_not_supported_title": "Notifikationer understøttes ikke", - "alert_not_supported_description": "Notifikationer understøttes ikke i din browser.", - "alert_not_supported_context_description": "Notifikationer understøttes kun via HTTPS. Dette skyldes en begrænsning i Notifications API.", - "nav_button_subscribe": "Abonner på emne", - "notifications_list_item": "Notifikation", - "notifications_delete": "Slet", - "notifications_tags": "Tags", - "notifications_list": "Notifikationsliste", - "notifications_mark_read": "Marker som læst", - "notifications_copied_to_clipboard": "Kopieret til udklipsholder", - "notifications_priority_x": "Prioritet {{priority}}", - "notifications_attachment_copy_url_title": "Kopier URL-adresse til vedhæftet fil til udklipsholder", - "notifications_attachment_copy_url_button": "Kopier URL", - "notifications_attachment_open_title": "Gå til {{url}}", - "notifications_attachment_open_button": "Åben vedhæftning", - "notifications_attachment_link_expires": "link udløber {{date}}", - "notifications_attachment_link_expired": "download link er udløbet", - "notifications_attachment_file_image": "billedfil", - "notifications_attachment_file_app": "Android app fil", - "notifications_attachment_file_document": "andet dokument", - "notifications_click_copy_url_title": "Kopier linkets URL til udklipsholderen", - "notifications_click_copy_url_button": "Kopier link", - "notifications_example": "Eksempel", - "notifications_click_open_button": "Åbn link", - "notifications_actions_not_supported": "Handlingen understøttes ikke i webappen", - "notifications_actions_http_request_title": "Send HTTP {{method}} til {{url}}", - "notifications_none_for_topic_title": "Du har ikke modtaget nogen notifikationer om dette emne endnu.", - "notifications_none_for_any_title": "Du har ikke modtaget nogen notifikationer.", - "display_name_dialog_placeholder": "Vist navn", - "publish_dialog_progress_uploading": "Uploader…", - "display_name_dialog_title": "Skift visningsnavn", - "publish_dialog_progress_uploading_detail": "Uploader {{loaded}}/{{total}} ({{percent}}%) …", - "publish_dialog_emoji_picker_show": "Vælg emoji", - "publish_dialog_priority_min": "Min. prioritet", - "publish_dialog_priority_low": "Lav prioritet", - "publish_dialog_priority_default": "Standardprioritet", - "publish_dialog_priority_high": "Høj prioritet", - "publish_dialog_title_label": "Titel", - "publish_dialog_message_label": "Besked", - "publish_dialog_tags_label": "Tags", - "publish_dialog_priority_label": "Prioritet", - "publish_dialog_message_placeholder": "Skriv en besked her", - "publish_dialog_tags_placeholder": "Komma-separeret liste over tags, f.eks. warning, srv1-backup", - "publish_dialog_click_label": "Klik på URL", - "publish_dialog_email_reset": "Fjern videresendelse af e-mail", - "publish_dialog_attach_placeholder": "Vedhæft fil via URL, f.eks. https://f-droid.org/F-Droid.apk", - "publish_dialog_delay_label": "Forsinkelse", - "publish_dialog_button_send": "Send", - "subscribe_dialog_subscribe_button_subscribe": "Tilmeld", - "common_back": "Tilbage", - "subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil", - "account_basics_title": "Konto", - "subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret", - "account_basics_username_admin_tooltip": "Du er Admin", - "account_basics_password_dialog_confirm_password_label": "Bekræft kodeord", - "account_basics_password_dialog_current_password_incorrect": "Forkert kodeord", - "account_usage_of_limit": "af {{limit}}", - "account_basics_tier_basic": "Grundlæggende", - "account_basics_tier_free": "Gratis", - "account_basics_tier_admin_suffix_no_tier": "(intet niveau)", - "account_basics_tier_admin_suffix_with_tier": "(med {{tier}}} niveau)", - "account_usage_messages_title": "Offentliggjorte meddelelser", - "account_delete_dialog_button_submit": "Slet konto permanent", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pr. fil", - "account_upgrade_dialog_button_redirect_signup": "Tilmeld dig nu", - "account_tokens_table_expires_header": "Udløber", - "account_tokens_table_last_access_header": "Seneste adgang", - "account_tokens_delete_dialog_title": "Slet adgangstoken", - "prefs_notifications_sound_no_sound": "Ingen lyd", - "prefs_notifications_min_priority_title": "Minimumsprioritet", - "prefs_notifications_sound_play": "Afspil den valgte lyd", - "prefs_notifications_min_priority_max_only": "Kun maks. prioritet", - "prefs_notifications_delete_after_three_hours": "Efter tre timer", - "prefs_users_add_button": "Tilføj bruger", - "prefs_users_dialog_title_edit": "Rediger bruger", - "prefs_reservations_title": "Reserverede emner", - "prefs_reservations_add_button": "Tilføj reserveret emne", - "prefs_reservations_table_access_header": "Adgang", - "prefs_reservations_delete_button": "Nulstil emneadgang", - "prefs_reservations_dialog_title_edit": "Rediger reserveret emne", - "prefs_reservations_dialog_access_label": "Adgang", - "prefs_reservations_dialog_title_delete": "Slet emnereservation", - "priority_low": "lav", - "priority_min": "min", - "reservation_delete_dialog_submit_button": "Slet reservation", - "priority_high": "høj", - "priority_max": "maks", - "error_boundary_stack_trace": "Strack trace", - "error_boundary_button_copy_stack_trace": "Kopier stack trace", - "signup_already_have_account": "Har du allerede en konto? Log ind!", - "action_bar_clear_notifications": "Ryd alle notifikationer", - "notifications_new_indicator": "Ny notifikation", - "notifications_attachment_image": "Vedhæftet billede", - "account_delete_dialog_label": "Kodeord", - "error_boundary_unsupported_indexeddb_title": "Privat browsing understøttes ikke", - "notifications_actions_open_url_title": "Gå til {{url}}", - "notifications_attachment_file_audio": "lydfil", - "publish_dialog_click_placeholder": "URL der åbnes, når der klikkes på notifikationen", - "publish_dialog_email_placeholder": "Adresse, som meddelelsen skal videresendes til, f.eks. phil@example.com", - "notifications_attachment_file_video": "videofil", - "account_basics_tier_title": "Kontotype", - "publish_dialog_filename_label": "Filnavn", - "account_basics_tier_manage_billing_button": "Administrer fakturering", - "account_usage_emails_title": "Afsendte e-mails", - "account_usage_reservations_title": "Reserverede emner", - "account_delete_title": "Slet konto", - "nav_button_account": "Konto", - "nav_button_documentation": "Dokumentation", - "publish_dialog_priority_max": "Maks. prioritet", - "account_upgrade_dialog_button_cancel_subscription": "Opsig abonnement", - "account_upgrade_dialog_button_update_subscription": "Opdater abonnement", - "publish_dialog_button_cancel": "Annuller", - "publish_dialog_email_label": "Email", - "account_tokens_title": "Adgangstokens", - "account_tokens_table_never_expires": "Udløber aldrig", - "prefs_notifications_sound_title": "Notifikationslyd", - "account_tokens_dialog_button_update": "Opdater token", - "account_tokens_dialog_button_create": "Opret token", - "subscribe_dialog_subscribe_button_cancel": "Annuller", - "prefs_users_table_user_header": "Bruger", - "prefs_appearance_title": "Udseende", - "subscribe_dialog_login_button_login": "Log ind", - "subscribe_dialog_login_password_label": "Kodeord", - "subscribe_dialog_error_user_anonymous": "anonym", - "account_usage_title": "Anvendelse", - "account_basics_username_title": "Brugernavn", - "account_basics_tier_admin": "Admin", - "account_basics_password_title": "Kodeord", - "account_upgrade_dialog_tier_selected_label": "Valgt", - "account_usage_unlimited": "Ubegrænset", - "account_tokens_table_label_header": "Label", - "account_tokens_dialog_button_cancel": "Annuller", - "account_basics_tier_change_button": "Rediger", - "account_delete_dialog_button_cancel": "Annuller", - "account_upgrade_dialog_button_cancel": "Annuller", - "account_tokens_table_token_header": "Token", - "account_upgrade_dialog_tier_current_label": "Nuværende", - "prefs_notifications_title": "Notifikationer", - "prefs_notifications_delete_after_never": "Aldrig", - "prefs_reservations_table_topic_header": "Emne", - "prefs_users_dialog_password_label": "Kodeord", - "prefs_appearance_language_title": "Sprog", - "prefs_reservations_dialog_topic_label": "Emne", - "priority_default": "standard", - "publish_dialog_attached_file_remove": "Fjern vedhæftet fil", - "prefs_users_table": "Bruger tabel", - "prefs_users_edit_button": "Rediger bruger", - "prefs_users_dialog_title_add": "Tilføj bruger", - "prefs_users_delete_button": "Slet bruger", - "account_tokens_table_copied_to_clipboard": "Adgangstoken kopieret", - "prefs_notifications_min_priority_any": "Enhver prioritet", - "prefs_notifications_delete_after_title": "Slet notifikationer", - "publish_dialog_delay_reset": "Fjern forsinket levering", - "prefs_users_title": "Administrer brugere", - "account_basics_password_dialog_button_submit": "Skift kodeord", - "prefs_reservations_dialog_title_add": "Reserver emne", - "account_basics_password_dialog_current_password_label": "Nuværende kodeord", - "account_basics_password_dialog_new_password_label": "Nyt kodeord", - "notifications_loading": "Indlæser notifikationer…", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daglige e-mails", - "account_tokens_table_create_token_button": "Opret adgangstoken", - "account_tokens_dialog_title_delete": "Slet adgangstoken", - "publish_dialog_chip_email_label": "Videresend til e-mail", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} samlet lagerplads", - "subscribe_dialog_subscribe_use_another_label": "Brug en anden server", - "account_basics_tier_upgrade_button": "Opgrader til Pro", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder", - "common_copy_to_clipboard": "Kopier til udklipsholder", - "prefs_reservations_edit_button": "Rediger emneadgang", - "account_upgrade_dialog_title": "Skift kontoniveau", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner", - "account_tokens_dialog_expires_never": "Token udløber aldrig", - "account_tokens_table_current_session": "Nuværende browsersession", - "account_tokens_dialog_title_edit": "Rediger adgangstoken", - "account_tokens_dialog_title_create": "Opret adgangstoken", - "prefs_notifications_delete_after_one_day": "Efter en dag", - "account_tokens_delete_dialog_submit_button": "Slet token permanent", - "prefs_notifications_delete_after_one_month": "Efter en måned", - "prefs_notifications_delete_after_one_week": "Efter en uge", - "prefs_users_dialog_username_label": "Brugernavn, f.eks. phil", - "prefs_notifications_delete_after_one_day_description": "Notifikationer slettes automatisk efter en dag", - "notifications_none_for_topic_description": "For at sende en notifikation til dette emne, skal du blot sende en PUT eller POST til emne-URL'en.", - "notifications_none_for_any_description": "For at sende en notifikation til et emne, skal du blot sende en PUT eller POST til emne-URL'en. Her er et eksempel med et af dine emner.", - "notifications_no_subscriptions_title": "Det ser ud til, at du ikke har nogen abonnementer endnu.", - "notifications_more_details": "For mere information, se webstedet eller dokumentationen.", - "display_name_dialog_description": "Angiv et alternativt navn for et emne, der vises på abonnementslisten. Dette gør det nemmere at identificere emner med komplicerede navne.", - "reserve_dialog_checkbox_label": "Reserver emne og konfigurer adgang", - "publish_dialog_attachment_limits_file_reached": "overskrider {{fileSizeLimit}} filgrænse", - "publish_dialog_attachment_limits_quota_reached": "overskrider kvote, {{remainingBytes}} tilbage", - "publish_dialog_topic_label": "Emnenavn", - "publish_dialog_topic_placeholder": "Emnenavn, f.eks. phil_alerts", - "publish_dialog_topic_reset": "Nulstil emne", - "publish_dialog_click_reset": "Fjern klik-URL", - "publish_dialog_delay_placeholder": "Forsink levering, f.eks. {{unixTimestamp}}, {{relativeTime}} eller \"{{naturalLanguage}}\" (kun på engelsk)", - "publish_dialog_other_features": "Andre funktioner:", - "publish_dialog_chip_attach_url_label": "Vedhæft fil via URL", - "publish_dialog_chip_attach_file_label": "Vedhæft lokal fil", - "publish_dialog_details_examples_description": "For eksempler og en detaljeret beskrivelse af alle afsendelsesfunktioner henvises til dokumentationen.", - "publish_dialog_button_cancel_sending": "Annuller afsendelse", - "publish_dialog_attached_file_title": "Vedhæftet fil:", - "emoji_picker_search_placeholder": "Søg emoji", - "emoji_picker_search_clear": "Ryd søgning", - "subscribe_dialog_subscribe_title": "Abonner på emne", - "subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_alerts", - "subscribe_dialog_subscribe_button_generate_topic_name": "Generer navn", - "subscribe_dialog_login_title": "Login påkrævet", - "subscribe_dialog_login_description": "Dette emne er adgangskodebeskyttet. Indtast venligst brugernavn og adgangskode for at abonnere.", - "subscribe_dialog_error_user_not_authorized": "Brugeren {{username}} er ikke autoriseret", - "account_basics_password_description": "Skift adgangskoden til din konto", - "account_usage_limits_reset_daily": "Brugsgrænser nulstilles dagligt ved midnat (UTC)", - "account_basics_tier_paid_until": "Abonnementet er betalt indtil {{date}} og fornys automatisk", - "account_basics_tier_payment_overdue": "Din betaling er forfalden. Opdater venligst din betalingsmetode, ellers bliver din konto snart nedgraderet.", - "account_basics_tier_canceled_subscription": "Dit abonnement blev annulleret og vil blive nedgraderet til en gratis konto den {{date}}.", - "account_usage_cannot_create_portal_session": "Kan ikke åbne faktureringsportalen", - "account_delete_description": "Slet din konto permanent", - "account_delete_dialog_description": "Dette vil slette din konto permanent, inklusive alle data, der er gemt på serveren. Efter sletning vil dit brugernavn være utilgængeligt i 7 dage. Hvis du virkelig ønsker at fortsætte, bedes du bekræfte med dit kodeord i feltet nedenfor.", - "account_upgrade_dialog_button_pay_now": "Betal nu og abonner", - "account_tokens_table_last_origin_tooltip": "Fra IP-adresse {{ip}}, klik for at slå op", - "account_tokens_dialog_label": "Label, f.eks. radarmeddelelser", - "account_tokens_dialog_expires_label": "Adgangstoken udløber om", - "account_tokens_dialog_expires_unchanged": "Lad udløbsdatoen forblive uændret", - "account_tokens_dialog_expires_x_hours": "Token udløber om {{hours}} timer", - "account_tokens_dialog_expires_x_days": "Token udløber om {{days}} dage", - "prefs_notifications_sound_description_none": "Notifikationer afspiller ingen lyd, når de ankommer", - "prefs_notifications_sound_description_some": "Notifikationer afspiller {{sound}}-lyden, når de ankommer", - "prefs_notifications_min_priority_low_and_higher": "Lav prioritet og højere", - "prefs_notifications_min_priority_default_and_higher": "Standardprioritet og højere", - "prefs_notifications_min_priority_high_and_higher": "Høj prioritet og højere", - "prefs_notifications_delete_after_never_description": "Notifikationer slettes aldrig automatisk", - "prefs_notifications_delete_after_three_hours_description": "Notifikationer slettes automatisk efter tre timer", - "prefs_notifications_delete_after_one_week_description": "Notifikationer slettes automatisk efter en uge", - "prefs_notifications_delete_after_one_month_description": "Notifikationer slettes automatisk efter en måned", - "prefs_reservations_limit_reached": "Du har nået din grænse for reserverede emner.", - "prefs_reservations_table_click_to_subscribe": "Klik for at abonnere", - "reservation_delete_dialog_action_keep_title": "Behold cachelagrede meddelelser og vedhæftede filer", - "reservation_delete_dialog_action_delete_title": "Slet cachelagrede meddelelser og vedhæftede filer", - "error_boundary_title": "Oh nej, ntfy brød sammen", - "error_boundary_description": "Dette bør naturligvis ikke ske. Det beklager vi meget.
Hvis du har et øjeblik, bedes du rapportere dette på GitHub, eller give os besked via Discord eller Matrix." -} diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 6343dee..a574dab 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -82,7 +82,7 @@ "publish_dialog_attach_placeholder": "Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk", "publish_dialog_filename_placeholder": "Dateiname des Anhangs", "publish_dialog_delay_label": "Verzögerung", - "publish_dialog_email_placeholder": "E-Mail-Adresse, an welche die Benachrichtigung gesendet werden soll, z. B. phil@example.com", + "publish_dialog_email_placeholder": "E-Mail-Adresse, an die die Benachrichtigung gesendet werden soll, z.B. phil@example.com", "publish_dialog_chip_click_label": "Klick-URL", "publish_dialog_button_cancel_sending": "Senden abbrechen", "publish_dialog_drop_file_here": "Datei hierher ziehen", @@ -94,7 +94,7 @@ "publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)", "prefs_appearance_title": "Darstellung", "subscribe_dialog_login_password_label": "Kennwort", - "common_back": "Zurück", + "subscribe_dialog_login_button_back": "Zurück", "publish_dialog_chip_attach_url_label": "Datei von URL anhängen", "publish_dialog_chip_delay_label": "Auslieferung verzögern", "publish_dialog_chip_topic_label": "Thema ändern", @@ -187,198 +187,5 @@ "publish_dialog_emoji_picker_show": "Emoji wählen", "publish_dialog_topic_reset": "Thema zurücksetzen", "publish_dialog_attach_reset": "angehängte URL entfernen", - "publish_dialog_click_reset": "Klick-URL entfernen", - "account_tokens_delete_dialog_description": "Stelle vor dem Löschen eines Access-Tokens sicher, dass keine Anwendung oder Skripte dieses Token verwenden. Diese Aktion kann nicht rückgängig gemacht werden.", - "account_upgrade_dialog_cancel_warning": "Dies wird Dein Abo stornieren und Dein Konto am {{date}} herabstufen. An diesem Datum werden reservierte Themen und auch auf dem Server gecachte Nachrichten gelöscht.", - "prefs_reservations_table_everyone_read_write": "Jeder kann veröffentlichen und lesen", - "prefs_reservations_table_everyone_read_only": "Ich kann veröffentlichen und lesen, jeder kann lesen", - "prefs_reservations_table_access_header": "Zugriff", - "account_tokens_dialog_button_cancel": "Abbrechen", - "account_tokens_dialog_expires_x_hours": "Token verfällt in {{hours}} Stunden", - "account_tokens_dialog_expires_never": "Token verfällt nie", - "signup_form_username": "Benutzername", - "signup_form_button_submit": "Konto anlegen", - "signup_already_have_account": "Du hast schon ein Konto? Melde Dich an!", - "signup_disabled": "Die Anmeldung ist deaktiviert", - "login_title": "Melde Dich mit Deinem ntfy-Konto an", - "login_form_button_submit": "Anmelden", - "login_link_signup": "Konto erstellen", - "login_disabled": "Anmeldung ist deaktiviert", - "action_bar_account": "Konto", - "action_bar_change_display_name": "Anzeigenamen ändern", - "action_bar_reservation_add": "Thema reservieren", - "action_bar_reservation_edit": "Reservierung ändern", - "action_bar_reservation_delete": "Reservierung löschen", - "action_bar_reservation_limit_reached": "Grenze erreicht", - "action_bar_profile_title": "Profil", - "action_bar_profile_settings": "Einstellungen", - "action_bar_profile_logout": "Abmelden", - "action_bar_sign_in": "Anmelden", - "signup_form_password": "Kennwort", - "signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten", - "nav_button_account": "Konto", - "nav_upgrade_banner_description": "Themen reservieren, mehr Nachrichten & Emails, größere Anhänge", - "display_name_dialog_title": "Anzeigennamen ändern", - "display_name_dialog_placeholder": "Anzeigename", - "reserve_dialog_checkbox_label": "Thema reservieren und Zugriffsrechte konfigurieren", - "subscribe_dialog_error_topic_already_reserved": "Thema ist bereits reserviert", - "account_basics_username_title": "Benutzername", - "account_basics_username_description": "Hey, das bist Du ❤", - "account_basics_password_description": "Konto-Kennwort ändern", - "account_basics_password_dialog_title": "Kennwort ändern", - "account_basics_password_dialog_current_password_label": "Aktuelles Kennwort", - "account_basics_password_dialog_new_password_label": "Neues Kennwort", - "account_basics_password_dialog_confirm_password_label": "Kennwort bestätigen", - "account_basics_password_dialog_current_password_incorrect": "Kennwort falsch", - "account_usage_title": "Verbrauch", - "account_usage_of_limit": "von {{limit}}", - "account_usage_unlimited": "unbegrenzt", - "account_usage_limits_reset_daily": "Verbrauchslimits werden täglich um Mitternacht (UTC) zurückgesetzt", - "account_basics_password_title": "Kennwort", - "account_basics_tier_description": "Der Funktionsumfang Deines Konto-Levels", - "account_basics_tier_admin_suffix_with_tier": "(mit Level {{tier}})", - "account_basics_tier_admin_suffix_no_tier": "(kein Level)", - "account_basics_tier_admin": "Admin", - "account_basics_tier_basic": "Basic", - "account_basics_tier_free": "Kostenlos", - "account_basics_tier_paid_until": "Abo bezahlt bis {{date}} mit automatischer Verlängerung", - "account_basics_tier_payment_overdue": "Deine Zahlung ist überfällig. Bitte aktualisiere Deine Zahlungsmethode, oder Dein Konto wird herabgestuft.", - "account_basics_tier_manage_billing_button": "Zahlung verwalten", - "account_usage_messages_title": "Veröffentlichte Nachrichten", - "account_usage_emails_title": "Gesendete Emails", - "account_usage_reservations_title": "Reservierte Themen", - "account_usage_reservations_none": "Keine reservierten Themen für dieses Konto", - "account_usage_attachment_storage_title": "Speicherplatz für Anhänge", - "account_usage_attachment_storage_description": "{{filesize}} pro Datei, Löschung nach {{expiry}}", - "account_usage_cannot_create_portal_session": "Kann Abrechnungsportal nicht öffnen", - "account_delete_title": "Konto löschen", - "account_delete_description": "Konto endgültig löschen", - "account_delete_dialog_label": "Kennwort", - "account_delete_dialog_button_cancel": "Abbrechen", - "account_delete_dialog_button_submit": "Lösche mein Konto endgültig", - "account_basics_tier_change_button": "Wechseln", - "account_basics_tier_canceled_subscription": "Dein Abo wurde storniert und wird am {{date}} auf ein kostenloses Konto herabgestuft.", - "account_usage_basis_ip_description": "Nutzungsstatistiken und Limits für diesen Account basieren auf Deiner IP-Adresse, können also mit anderen Usern geteilt sein. Die oben gezeigten Limits sind Schätzungen basierend auf den bestehenden Limits.", - "account_delete_dialog_billing_warning": "Das Löschen Deines Kontos storniert auch sofort Deine Zahlung. Du wirst dann keinen Zugang zum Abrechnungs-Dashboard haben.", - "account_upgrade_dialog_title": "Konto-Level ändern", - "account_upgrade_dialog_proration_info": "Anrechnung: Wenn Du auf einen höheren kostenpflichtigen Level wechselst wird die Differenz sofort berechnet. Beim Wechsel auf ein kleineres Level verwenden wir Dein Guthaben für zukünftige Abrechnungsperioden.", - "account_upgrade_dialog_reservations_warning_one": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. Bitte löschen vor dem Wechsel Deines Levels mindestens eine Reservierung. Du kannst Reservierungen in den Einstellungen löschen.", - "account_upgrade_dialog_reservations_warning_other": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. Bitte löschen vor dem Wechsel Deines Levels mindestens {{count}} Reservierungen. Du kannst Reservierungen in den Einstellungen löschen.", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reservierte Themen", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} Nachrichten pro Tag", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} Emails pro Tag", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pro Datei", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} gesamter Speicherplatz", - "account_upgrade_dialog_tier_selected_label": "Ausgewählt", - "account_upgrade_dialog_tier_current_label": "Aktuell", - "account_upgrade_dialog_button_cancel": "Abbrechen", - "account_upgrade_dialog_button_redirect_signup": "Jetzt ein Konto anlegen", - "account_upgrade_dialog_button_pay_now": "Jetzt bezahlen und abonnieren", - "account_upgrade_dialog_button_cancel_subscription": "Abo stornieren", - "account_upgrade_dialog_button_update_subscription": "Abo aktualisieren", - "account_tokens_title": "Access-Token", - "account_tokens_description": "Verwende Access-Token zum Versenden und Empfangen über die ntfy-API, um nicht Deine Zugangsdaten verwenden zu müssen. Lies die Dokumentation für mehr Info.", - "account_tokens_table_token_header": "Token", - "account_tokens_table_label_header": "Bezeichnung", - "account_tokens_table_last_access_header": "Letzter Zugriff", - "account_tokens_table_expires_header": "Verfällt", - "account_tokens_table_never_expires": "Verfällt nie", - "account_tokens_table_current_session": "Aktuelle Browser-Sitzung", - "common_copy_to_clipboard": "In die Zwischenablage kopieren", - "account_tokens_table_copied_to_clipboard": "Access-Token kopiert", - "account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden", - "account_tokens_table_create_token_button": "Access-Token erzeugen", - "account_tokens_table_last_origin_tooltip": "Von IP-Adresse {{ip}}, klicke zum Nachschlagen", - "account_tokens_dialog_title_create": "Access-Token erzeugen", - "account_tokens_dialog_title_edit": "Access-Token bearbeiten", - "account_tokens_dialog_title_delete": "Access-Token löschen", - "account_tokens_dialog_label": "Bezeichnung, z.B. Radarr Benachrichtigungen", - "account_tokens_dialog_button_create": "Token erzeugen", - "account_tokens_dialog_button_update": "Token aktualisieren", - "account_tokens_dialog_expires_label": "Access-Token verfällt in", - "account_tokens_dialog_expires_unchanged": "Verfallsdatum nicht ändern", - "account_tokens_dialog_expires_x_days": "Token verfällt in {{days}} Tagen", - "account_tokens_delete_dialog_title": "Access-Token löschen", - "account_tokens_delete_dialog_submit_button": "Token endgültig löschen", - "prefs_users_description_no_sync": "Benutzernamen und Kennwörter werden nicht im Konto synchronisiert.", - "prefs_users_table_cannot_delete_or_edit": "Angemeldeter Benutzer kann nicht gelöscht oder bearbeitet werden", - "prefs_reservations_title": "Reservierte Themen", - "prefs_reservations_description": "Du kannst hier Themen-Namen für Deine persönliche Verwendung reservieren. Das Reservieren eines Themas macht Dich zum Besitzer des Themas. Du kannst damit auch Zugriffsrechte für andere Benutzer auf das Thema festlegen.", - "prefs_reservations_limit_reached": "Du hast Dein Limit an reservierten Themen erreicht.", - "prefs_reservations_add_button": "Reserviertes Thema hinzufügen", - "prefs_reservations_edit_button": "Zugriff auf Thema bearbeiten", - "prefs_reservations_delete_button": "Zugriff auf Thema zurücksetzen", - "prefs_reservations_table": "Übersicht reservierter Themen", - "prefs_reservations_table_topic_header": "Thema", - "prefs_reservations_table_everyone_deny_all": "Nur kann veröffentlichen und lesen", - "prefs_reservations_table_everyone_write_only": "Ich kann veröffentlichen und lesen, jeder kann veröffentlichen", - "prefs_reservations_table_not_subscribed": "Nicht abonniert", - "prefs_reservations_table_click_to_subscribe": "Klicken um zu abonnieren", - "prefs_reservations_dialog_title_add": "Thema reservieren", - "prefs_reservations_dialog_title_edit": "Reserviertes Thema bearbeiten", - "prefs_reservations_dialog_title_delete": "Thema-Reservierung löschen", - "prefs_reservations_dialog_description": "Ein Thema zu reservieren macht Dich zum Besitzer des Themas, und erlaubt Dir Zugriffsrechte für andere auf dieses Thema festzulegen.", - "prefs_reservations_dialog_topic_label": "Thema", - "prefs_reservations_dialog_access_label": "Zugriff", - "reservation_delete_dialog_description": "Mit dem Löschen einer Reservierung gibst du den Besitz des Themas auf und ermöglichst anderen, es zu reservieren. Du kannst vorhandene Nachrichten und Dateien behalten oder löschen.", - "reservation_delete_dialog_action_keep_title": "Behalte gecachte Nachrichten und Dateien", - "reservation_delete_dialog_action_keep_description": "Nachrichten und Dateien, die auf dem Server gecached sind, werden für alle sichtbar die den Themen-Namen kennen.", - "reservation_delete_dialog_action_delete_title": "Löschen gecachte Nachrichten und Dateien", - "reservation_delete_dialog_action_delete_description": "Gecachte Nachrichten und Dateien werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", - "reservation_delete_dialog_submit_button": "Reservierung löschen", - "account_basics_password_dialog_button_submit": "Kennwort ändern", - "account_basics_tier_title": "Kontotyp", - "account_basics_tier_upgrade_button": "Upgrade auf Pro", - "account_delete_dialog_description": "Hiermit wird Dein Konto endgültig gelöscht, inklusive aller Daten auf dem Server. Nach dem Löschen wird Dein Benutzername für 7 Tage gesperrt sein. Wenn Du fortfahren willst, bestätige das durch Eingabe Deines Kennwortes.", - "signup_form_confirm_password": "Kennwort wiederholen", - "signup_title": "Erstelle ein ntfy-Konto", - "signup_error_username_taken": "Benutzername {{username}} ist bereits vergeben", - "signup_error_creation_limit_reached": "Grenze der Account-Erstellung erreicht", - "subscribe_dialog_subscribe_button_generate_topic_name": "Namen erzeugen", - "account_basics_title": "Konto", - "action_bar_sign_up": "Konto erstellen", - "nav_upgrade_banner_label": "Upgrade auf ntfy Pro", - "alert_not_supported_context_description": "Benachrichtigungen werden nur über HTTPS unterstützt. Das ist eine Einschränkung der Notifications API.", - "display_name_dialog_description": "Lege einen alternativen Namen für ein Thema fest, der in der Abo-Liste angezeigt wird. So kannst Du Themen mit komplizierten Namen leichter finden.", - "account_basics_username_admin_tooltip": "Du bist Admin", - "account_upgrade_dialog_interval_yearly_discount_save": "spare {{discount}}%", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "spare bis zu {{discount}}%", - "account_upgrade_dialog_tier_price_per_month": "Monat", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} pro Jahr. Spare {{save}}.", - "account_upgrade_dialog_billing_contact_email": "Bei Fragen zur Abrechnung, kontaktiere uns bitte direkt.", - "account_upgrade_dialog_billing_contact_website": "Bei Fragen zur Abrechnung sieh bitte auf unserer Webseite nach.", - "account_upgrade_dialog_tier_features_no_reservations": "Keine reservierten Themen", - "account_basics_tier_interval_yearly": "jährlich", - "account_basics_tier_interval_monthly": "monatlich", - "account_upgrade_dialog_interval_monthly": "Monatlich", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} pro Jahr. Monatlich abgerechnet.", - "account_upgrade_dialog_interval_yearly": "Jährlich", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} tägliche Nachricht", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserviertes Thema", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} tägliche E-Mail", - "publish_dialog_call_label": "Telefonanruf", - "publish_dialog_call_item": "Telefonnummer {{number}} anrufen", - "publish_dialog_chip_call_label": "Telefonanruf", - "publish_dialog_chip_call_no_verified_numbers_tooltip": "Keine verifizierten Telefonnummern", - "account_basics_phone_numbers_title": "Telefonnummern", - "account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer wurde in die Zwischenablage kopiert", - "account_basics_phone_numbers_dialog_title": "Telefonnummer hinzufügen", - "account_upgrade_dialog_tier_features_calls_other": "{{calls}} Telefonanrufe pro Tag", - "account_upgrade_dialog_tier_features_no_calls": "Keine Telefonanrufe", - "publish_dialog_call_reset": "Telefonanruf entfernen", - "account_basics_phone_numbers_dialog_description": "Um die Benachrichtigung per Telefonanruf zu nutzen musst Du mindestens eine Telefonnummer hinzufügen und verifizieren. Die Verifizierung kann per SMS oder über einen Anruf erfolgen.", - "account_basics_phone_numbers_description": "Für Telefon-Benachrichtigungen", - "account_basics_phone_numbers_no_phone_numbers_yet": "Noch keine Telefonnummern", - "account_basics_phone_numbers_dialog_number_label": "Telefonnummer", - "account_basics_phone_numbers_dialog_channel_sms": "SMS", - "account_basics_phone_numbers_dialog_channel_call": "Anruf", - "account_basics_phone_numbers_dialog_number_placeholder": "z.B. +49123456789", - "account_basics_phone_numbers_dialog_verify_button_call": "Ruf mich an", - "account_basics_phone_numbers_dialog_verify_button_sms": "SMS senden", - "account_basics_phone_numbers_dialog_code_label": "Verifizierungs-Code", - "account_basics_phone_numbers_dialog_code_placeholder": "z.B. 123456", - "account_basics_phone_numbers_dialog_check_verification_button": "Code bestätigen", - "account_usage_calls_title": "Getätigte Anrufe", - "account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt", - "account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag" + "publish_dialog_click_reset": "Klick-URL entfernen" } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 5d8a3a3..04f98e4 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -2,8 +2,6 @@ "common_cancel": "Cancel", "common_save": "Save", "common_add": "Add", - "common_back": "Back", - "common_copy_to_clipboard": "Copy to clipboard", "signup_title": "Create a ntfy account", "signup_form_username": "Username", "signup_form_password": "Password", @@ -129,9 +127,6 @@ "publish_dialog_email_label": "Email", "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_reset": "Remove email forward", - "publish_dialog_call_label": "Phone call", - "publish_dialog_call_item": "Call phone number {{number}}", - "publish_dialog_call_reset": "Remove phone call", "publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", "publish_dialog_attach_reset": "Remove attachment URL", @@ -143,8 +138,6 @@ "publish_dialog_other_features": "Other features:", "publish_dialog_chip_click_label": "Click URL", "publish_dialog_chip_email_label": "Forward to email", - "publish_dialog_chip_call_label": "Phone call", - "publish_dialog_chip_call_no_verified_numbers_tooltip": "No verified phone numbers", "publish_dialog_chip_attach_url_label": "Attach file by URL", "publish_dialog_chip_attach_file_label": "Attach local file", "publish_dialog_chip_delay_label": "Delay delivery", @@ -172,6 +165,7 @@ "subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.", "subscribe_dialog_login_username_label": "Username, e.g. phil", "subscribe_dialog_login_password_label": "Password", + "subscribe_dialog_login_button_back": "Back", "subscribe_dialog_login_button_login": "Login", "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized", "subscribe_dialog_error_topic_already_reserved": "Topic already reserved", @@ -188,21 +182,6 @@ "account_basics_password_dialog_confirm_password_label": "Confirm password", "account_basics_password_dialog_button_submit": "Change password", "account_basics_password_dialog_current_password_incorrect": "Password incorrect", - "account_basics_phone_numbers_title": "Phone numbers", - "account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Verification can be done via SMS or a phone call.", - "account_basics_phone_numbers_description": "For phone call notifications", - "account_basics_phone_numbers_no_phone_numbers_yet": "No phone numbers yet", - "account_basics_phone_numbers_copied_to_clipboard": "Phone number copied to clipboard", - "account_basics_phone_numbers_dialog_title": "Add phone number", - "account_basics_phone_numbers_dialog_number_label": "Phone number", - "account_basics_phone_numbers_dialog_number_placeholder": "e.g. +1222333444", - "account_basics_phone_numbers_dialog_verify_button_sms": "Send SMS", - "account_basics_phone_numbers_dialog_verify_button_call": "Call me", - "account_basics_phone_numbers_dialog_code_label": "Verification code", - "account_basics_phone_numbers_dialog_code_placeholder": "e.g. 123456", - "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code", - "account_basics_phone_numbers_dialog_channel_sms": "SMS", - "account_basics_phone_numbers_dialog_channel_call": "Call", "account_usage_title": "Usage", "account_usage_of_limit": "of {{limit}}", "account_usage_unlimited": "Unlimited", @@ -214,8 +193,6 @@ "account_basics_tier_admin_suffix_no_tier": "(no tier)", "account_basics_tier_basic": "Basic", "account_basics_tier_free": "Free", - "account_basics_tier_interval_monthly": "monthly", - "account_basics_tier_interval_yearly": "annually", "account_basics_tier_upgrade_button": "Upgrade to Pro", "account_basics_tier_change_button": "Change", "account_basics_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew", @@ -224,8 +201,6 @@ "account_basics_tier_manage_billing_button": "Manage billing", "account_usage_messages_title": "Published messages", "account_usage_emails_title": "Emails sent", - "account_usage_calls_title": "Phone calls made", - "account_usage_calls_none": "No phone calls can be made with this account", "account_usage_reservations_title": "Reserved topics", "account_usage_reservations_none": "No reserved topics for this account", "account_usage_attachment_storage_title": "Attachment storage", @@ -240,33 +215,17 @@ "account_delete_dialog_button_submit": "Permanently delete account", "account_delete_dialog_billing_warning": "Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.", "account_upgrade_dialog_title": "Change account tier", - "account_upgrade_dialog_interval_monthly": "Monthly", - "account_upgrade_dialog_interval_yearly": "Annually", - "account_upgrade_dialog_interval_yearly_discount_save": "save {{discount}}%", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "save up to {{discount}}%", "account_upgrade_dialog_cancel_warning": "This will cancel your subscription, and downgrade your account on {{date}}. On that date, topic reservations as well as messages cached on the server will be deleted.", - "account_upgrade_dialog_proration_info": "Proration: When upgrading between paid plans, the price difference will be charged immediately. When downgrading to a lower tier, the balance will be used to pay for future billing periods.", + "account_upgrade_dialog_proration_info": "Proration: When switching between paid plans, the price difference will be charged or refunded in the next invoice. You will not receive another invoice until the end of the next billing period.", "account_upgrade_dialog_reservations_warning_one": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, please delete at least one reservation. You can remove reservations in the Settings.", "account_upgrade_dialog_reservations_warning_other": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, please delete at least {{count}} reservations. You can remove reservations in the Settings.", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserved topic", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserved topics", - "account_upgrade_dialog_tier_features_no_reservations": "No reserved topics", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} daily message", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails", - "account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls", - "account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls", - "account_upgrade_dialog_tier_features_no_calls": "No phone calls", + "account_upgrade_dialog_tier_features_reservations": "{{reservations}} reserved topics", + "account_upgrade_dialog_tier_features_messages": "{{messages}} daily messages", + "account_upgrade_dialog_tier_features_emails": "{{emails}} daily emails", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", - "account_upgrade_dialog_tier_price_per_month": "month", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per year. Billed monthly.", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.", "account_upgrade_dialog_tier_selected_label": "Selected", "account_upgrade_dialog_tier_current_label": "Current", - "account_upgrade_dialog_billing_contact_email": "For billing questions, please contact us directly.", - "account_upgrade_dialog_billing_contact_website": "For billing questions, please refer to our website.", "account_upgrade_dialog_button_cancel": "Cancel", "account_upgrade_dialog_button_redirect_signup": "Sign up now", "account_upgrade_dialog_button_pay_now": "Pay now and subscribe", @@ -280,6 +239,7 @@ "account_tokens_table_expires_header": "Expires", "account_tokens_table_never_expires": "Never expires", "account_tokens_table_current_session": "Current browser session", + "account_tokens_table_copy_to_clipboard": "Copy to clipboard", "account_tokens_table_copied_to_clipboard": "Access token copied", "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token", "account_tokens_table_create_token_button": "Create access token", diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json index 62ecdaf..3f06b9d 100644 --- a/web/public/static/langs/es.json +++ b/web/public/static/langs/es.json @@ -81,7 +81,7 @@ "subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.", "subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil", "subscribe_dialog_login_password_label": "Contraseña", - "common_back": "Volver", + "subscribe_dialog_login_button_back": "Volver", "subscribe_dialog_login_button_login": "Iniciar sesión", "subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado", "subscribe_dialog_error_user_anonymous": "anónimo", @@ -107,7 +107,7 @@ "prefs_appearance_language_title": "Idioma", "error_boundary_title": "Oh no, ntfy tuvo un error", "error_boundary_button_copy_stack_trace": "Copiar el stack trace", - "error_boundary_stack_trace": "Rastreo de pila", + "error_boundary_stack_trace": "Stack trace", "error_boundary_gathering_info": "Reunir más información …", "notifications_example": "Ejemplo", "prefs_notifications_min_priority_title": "Prioridad mínima", @@ -187,199 +187,5 @@ "prefs_users_table": "Tabla de usuarios", "prefs_users_edit_button": "Editar usuario", "prefs_users_delete_button": "Eliminar usuario", - "error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada", - "action_bar_profile_title": "Perfil", - "action_bar_profile_settings": "Configuración", - "signup_title": "Crear una cuenta ntfy", - "signup_form_username": "Nombre de usuario", - "signup_form_password": "Contraseña", - "signup_form_confirm_password": "Confirmar contraseña", - "signup_form_button_submit": "Registro", - "signup_form_toggle_password_visibility": "Alternar la visibilidad de la contraseña", - "signup_already_have_account": "¿Ya tienes una cuenta? ¡Iniciar sesión!", - "signup_disabled": "El registro está deshabilitado", - "signup_error_username_taken": "El nombre de usuario {{username}} ya está en uso", - "signup_error_creation_limit_reached": "Límite de creación de cuenta alcanzado", - "login_title": "Inicie sesión en su cuenta ntfy", - "login_form_button_submit": "Iniciar sesión", - "login_link_signup": "Registro", - "login_disabled": "Inicio de sesión deshabilitado", - "action_bar_account": "Cuenta", - "action_bar_change_display_name": "Cambiar nombre de usuario", - "action_bar_reservation_add": "Reservar tema", - "action_bar_reservation_edit": "Modificar reserva", - "action_bar_reservation_delete": "Quitar reserva", - "action_bar_reservation_limit_reached": "Límite alcanzado", - "action_bar_profile_logout": "Cerrar sesión", - "action_bar_sign_in": "Iniciar sesión", - "action_bar_sign_up": "Registro", - "nav_button_account": "Cuenta", - "nav_upgrade_banner_label": "Actualizar a ntfy Pro", - "nav_upgrade_banner_description": "Reserve temas, más mensajes y correos electrónicos, y archivos adjuntos más grandes", - "display_name_dialog_title": "Cambiar el nombre para mostrar", - "display_name_dialog_description": "Establezca un nombre alternativo para un tópico que se muestra en la lista de suscripciones. Esto ayuda a identificar más fácilmente los temas con nombres complicados.", - "display_name_dialog_placeholder": "Nombre para mostrar", - "account_basics_username_admin_tooltip": "Eres Administrador", - "account_basics_password_description": "Cambiar la contraseña de tu cuenta", - "account_basics_password_dialog_confirm_password_label": "Confirmar contraseña", - "account_basics_password_dialog_button_submit": "Cambiar contraseña", - "account_basics_password_dialog_current_password_incorrect": "Contraseña incorrecta", - "account_usage_unlimited": "Ilimitado", - "account_usage_title": "Uso", - "account_usage_of_limit": "de {{límite}}", - "account_usage_limits_reset_daily": "Los límites de uso se restablecen diariamente a la medianoche (UTC)", - "account_basics_tier_description": "Nivel de poder de tu cuenta", - "account_basics_tier_admin": "Administrador", - "alert_not_supported_context_description": "Las notificaciones sólo se admiten a través de HTTPS. Esta es una limitante de la API de notificaciones .", - "reserve_dialog_checkbox_label": "Reservar tópico y configurar el acceso", - "subscribe_dialog_subscribe_button_generate_topic_name": "Generar nombre", - "subscribe_dialog_error_topic_already_reserved": "Tópico ya reservado", - "account_basics_title": "Cuenta", - "account_basics_username_title": "Nombre de usuario", - "account_basics_username_description": "Hey, ese eres tú ❤", - "account_basics_password_title": "Contraseña", - "account_basics_password_dialog_title": "Cambiar contraseña", - "account_basics_password_dialog_current_password_label": "Contraseña actual", - "account_basics_password_dialog_new_password_label": "Contraseña nueva", - "account_basics_tier_basic": "Básico", - "account_basics_tier_admin_suffix_with_tier": "(con nivel {{tier}})", - "account_basics_tier_admin_suffix_no_tier": "(sin nivel)", - "account_basics_tier_free": "Gratis", - "account_basics_tier_upgrade_button": "Actualizar a Pro", - "account_basics_tier_change_button": "Cambiar", - "account_basics_tier_paid_until": "Suscripción pagada hasta {{fecha}}, y se renovará automáticamente", - "account_basics_tier_manage_billing_button": "Administrar la facturación", - "account_basics_tier_title": "Tipo de cuenta", - "account_tokens_description": "Utilice tokens de acceso al publicar y suscribirse a través de la API de ntfy para no tener que enviar las credenciales de su cuenta. Consulte la documentación para obtener más información.", - "account_tokens_table_token_header": "Token", - "account_tokens_table_label_header": "Etiqueta", - "account_tokens_table_last_access_header": "Último acceso", - "account_tokens_table_expires_header": "Expira", - "account_tokens_table_never_expires": "Nunca expira", - "account_tokens_table_current_session": "Sesión del navegador actual", - "common_copy_to_clipboard": "Copiar al portapapeles", - "account_tokens_table_copied_to_clipboard": "Token de acceso copiado", - "account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual", - "account_tokens_table_create_token_button": "Crear token de acceso", - "account_tokens_table_last_origin_tooltip": "Desde la dirección IP {{ip}}, haga clic para buscar", - "account_tokens_dialog_title_create": "Crear token de acceso", - "account_tokens_dialog_title_edit": "Editar token de acceso", - "account_tokens_dialog_title_delete": "Eliminar token de acceso", - "account_tokens_dialog_label": "Etiqueta, por ejemplo, notificaciones de Radarr", - "account_tokens_dialog_button_create": "Crear token", - "prefs_reservations_table_everyone_write_only": "Puedo publicar y suscribirme, todo el mundo puede publicar", - "account_usage_messages_title": "Mensajes publicados", - "account_usage_reservations_title": "Tópicos reservados", - "account_usage_reservations_none": "No hay tópicos reservados para esta cuenta", - "account_usage_cannot_create_portal_session": "No se puede abrir el portal de facturación", - "account_upgrade_dialog_title": "Cambiar nivel de cuenta", - "account_basics_tier_payment_overdue": "Su pago ha vencido. Por favor actualice su método de pago o su cuenta será degradada en breve.", - "account_basics_tier_canceled_subscription": "Su suscripción fue cancelada y será degradada a una cuenta gratuita el {{date}}.", - "account_usage_emails_title": "Correos enviados", - "account_usage_attachment_storage_title": "Almacenamiento de archivos adjuntos", - "account_usage_attachment_storage_description": "{{filesize}} por archivo, eliminado después de {{expiry}}", - "account_usage_basis_ip_description": "Las estadísticas de uso y los límites de esta cuenta se basan en su dirección IP, por lo que podrían ser compartidos con otros usuarios. Los límites mostrados anteriormente son aproximados basados en los límites existentes.", - "account_delete_title": "Elimina cuenta", - "account_delete_dialog_button_cancel": "Cancelar", - "account_delete_dialog_billing_warning": "La eliminación de su cuenta también cancela su suscripción de facturación inmediatamente. Ya no tendrá acceso al panel de facturación.", - "account_upgrade_dialog_reservations_warning_one": "El nivel seleccionado permite menos tópicos reservados que su nivel actual. Antes de cambiar de nivel, por favor elimine al menos una reserva. Puede eliminar reservas en Configuración.", - "account_upgrade_dialog_tier_selected_label": "Seleccionado", - "account_upgrade_dialog_button_cancel": "Cancelar", - "account_upgrade_dialog_button_cancel_subscription": "Cancelar suscripción", - "account_tokens_title": "Tokens de acceso", - "account_delete_description": "Eliminar permanentemente su cuenta", - "account_delete_dialog_description": "Esto borrará permanentemente su cuenta, incluyendo todos los datos almacenados en el servidor. Tras la eliminación, su nombre de usuario no estará disponible durante 7 días. Si realmente desea continuar, por favor confirme su contraseña en la casilla de abajo.", - "account_delete_dialog_label": "Contraseña", - "account_delete_dialog_button_submit": "Eliminar permanentemente la cuenta", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} tópicos reservados", - "account_upgrade_dialog_cancel_warning": "Esto cancelará su suscripción y degradará su cuenta en {{date}}. En esa fecha, sus tópicos reservados y sus mensajes almacenados en caché en el servidor serán eliminados.", - "account_upgrade_dialog_proration_info": "Prorrateo: al actualizar entre planes pagos, la diferencia de precio se cobrará de inmediato. Al cambiar a un nivel inferior, el saldo se utilizará para pagar futuros períodos de facturación.", - "account_upgrade_dialog_reservations_warning_other": "El nivel seleccionado permite menos tópicos reservados que su nivel actual. Antes de cambiar de nivel, por favor elimine al menos {{count}} reservaciones. Puede eliminar reservaciones en Configuración.", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} mensajes diarios", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} correos diarios", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} por archivo", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} almacenamiento total", - "account_upgrade_dialog_tier_current_label": "Actual", - "account_upgrade_dialog_button_redirect_signup": "Regístrese ahora", - "account_upgrade_dialog_button_pay_now": "Pague ahora y suscríbase", - "account_upgrade_dialog_button_update_subscription": "Actualizar suscripción", - "account_tokens_dialog_button_update": "Actualizar token", - "account_tokens_dialog_expires_label": "El token de acceso expira en", - "prefs_reservations_table": "Tabla de tópicos reservados", - "prefs_reservations_dialog_description": "Reservar un tópico le otorga la propiedad sobre el mismo y le permite definir permisos de acceso para otros usuarios sobre el tópico.", - "account_tokens_dialog_button_cancel": "Cancelar", - "account_tokens_dialog_expires_unchanged": "No modificar la fecha de expiración", - "prefs_reservations_add_button": "Agregar tópico reservado", - "prefs_reservations_table_access_header": "Acceso", - "reservation_delete_dialog_action_delete_description": "Los mensajes y archivos adjuntos almacenados en caché se eliminarán de forma permanente. Esta acción no se puede deshacer.", - "account_tokens_dialog_expires_x_hours": "El token expira en {{hours}} horas", - "account_tokens_delete_dialog_title": "Eliminar token de acceso", - "prefs_reservations_limit_reached": "Ha alcanzado su límite de tópicos reservados.", - "prefs_reservations_table_everyone_read_write": "Todo el mundo puede publicar y suscribirse", - "reservation_delete_dialog_action_keep_description": "Los mensajes y archivos adjuntos que se almacenen en caché en el servidor pasarán a ser visibles públicamente para las personas que conozcan el nombre del tópico.", - "account_tokens_dialog_expires_x_days": "El token expira en {{days}} días", - "account_tokens_dialog_expires_never": "El token nunca expira", - "account_tokens_delete_dialog_description": "Antes de eliminar un token de acceso, asegúrese de que ninguna aplicación o script lo está utilizando activamente. Esta acción no se puede deshacer.", - "prefs_users_table_cannot_delete_or_edit": "No se puede eliminar o editar el usuario conectado", - "prefs_reservations_title": "Tópicos reservados", - "prefs_reservations_edit_button": "Editar acceso al tópico", - "prefs_reservations_table_topic_header": "Tópico", - "prefs_reservations_table_everyone_read_only": "Puedo publicar y suscribirme, todo el mundo puede suscribirse", - "prefs_reservations_table_everyone_deny_all": "Sólo yo puedo publicar y suscribirme", - "prefs_reservations_table_click_to_subscribe": "Haga clic para suscribirse", - "prefs_reservations_dialog_title_edit": "Edita tópico reservado", - "account_tokens_delete_dialog_submit_button": "Eliminar permanentemente el token", - "prefs_reservations_description": "Aquí puede reservar nombres de tópicos para uso personal. Reservar un tópico le otorga la propiedad sobre el mismo y le permite definir permisos de acceso para otros usuarios sobre el tópico.", - "prefs_reservations_delete_button": "Restablecer acceso a tópico", - "prefs_reservations_table_not_subscribed": "No suscrito", - "prefs_reservations_dialog_title_add": "Reservar tópico", - "prefs_users_description_no_sync": "Los usuarios y las contraseñas no están sincronizados con su cuenta.", - "prefs_reservations_dialog_title_delete": "Borrar reserva de tópico", - "prefs_reservations_dialog_access_label": "Acceso", - "reservation_delete_dialog_action_keep_title": "Conservar mensajes y archivos adjuntos en caché", - "prefs_reservations_dialog_topic_label": "Tópico", - "reservation_delete_dialog_description": "Al eliminar una reserva se renuncia a la propiedad sobre el tópico y se permite que otros lo reserven. Puede conservar o eliminar los mensajes y archivos adjuntos existentes.", - "reservation_delete_dialog_action_delete_title": "Eliminar mensajes y archivos adjuntos en caché", - "reservation_delete_dialog_submit_button": "Eliminar reserva", - "account_basics_tier_interval_monthly": "mensualmente", - "account_basics_tier_interval_yearly": "anualmente", - "account_upgrade_dialog_interval_monthly": "Mensualmente", - "account_upgrade_dialog_interval_yearly": "Anualmente", - "account_upgrade_dialog_interval_yearly_discount_save": "ahorrar {{discount}}%", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "ahorra hasta un {{discount}}%", - "account_upgrade_dialog_tier_features_no_reservations": "Ningún tema reservado", - "account_upgrade_dialog_tier_price_per_month": "mes", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} facturado anualmente. Guardar {{save}}.", - "account_upgrade_dialog_billing_contact_website": "Si tiene preguntas sobre facturación, consulte nuestra página web.", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} al año. Facturación mensual.", - "account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor contáctenos directamente.", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensaje diario", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} correo electrónico diario", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado", - "publish_dialog_call_label": "Llamada telefónica", - "publish_dialog_call_placeholder": "Número de teléfono al cual llamar con el mensaje, por ejemplo +12223334444, o \"sí\"", - "publish_dialog_chip_call_label": "Llamada telefónica", - "account_basics_phone_numbers_title": "Números de teléfono", - "account_basics_phone_numbers_description": "Para notificaciones por llamada teléfonica", - "account_basics_phone_numbers_no_phone_numbers_yet": "Aún no hay números de teléfono", - "account_basics_phone_numbers_dialog_number_label": "Número de teléfono", - "account_basics_phone_numbers_dialog_number_placeholder": "p. ej. +1222333444", - "account_basics_phone_numbers_dialog_verify_button_sms": "Envía SMS", - "account_basics_phone_numbers_dialog_verify_button_call": "Llámame", - "account_basics_phone_numbers_dialog_code_label": "Código de verificación", - "account_basics_phone_numbers_dialog_channel_sms": "SMS", - "account_basics_phone_numbers_dialog_channel_call": "Llamar", - "account_usage_calls_title": "Llamadas telefónicas realizadas", - "account_usage_calls_none": "No se pueden hacer llamadas telefónicas con esta cuenta", - "account_upgrade_dialog_tier_features_calls_one": "{{llamadas}} llamadas telefónicas diarias", - "account_upgrade_dialog_tier_features_calls_other": "{{llamadas}} llamadas telefónicas diarias", - "account_upgrade_dialog_tier_features_no_calls": "No hay llamadas telefónicas", - "publish_dialog_call_reset": "Eliminar llamada telefónica", - "account_basics_phone_numbers_dialog_description": "Para utilizar la función de notificación de llamadas, tiene que añadir y verificar al menos un número de teléfono. La verificación puede realizarse mediante un SMS o una llamada telefónica.", - "account_basics_phone_numbers_copied_to_clipboard": "Número de teléfono copiado al portapapeles", - "account_basics_phone_numbers_dialog_check_verification_button": "Confirmar código", - "account_basics_phone_numbers_dialog_title": "Agregar número de teléfono", - "account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456", - "publish_dialog_call_item": "Llamar al número de teléfono {{number}}", - "publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados" + "error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada" } diff --git a/web/public/static/langs/fr.json b/web/public/static/langs/fr.json index cf4bb72..2304b98 100644 --- a/web/public/static/langs/fr.json +++ b/web/public/static/langs/fr.json @@ -106,7 +106,7 @@ "prefs_notifications_title": "Notifications", "prefs_notifications_delete_after_title": "Supprimer les notifications", "prefs_users_add_button": "Ajouter un utilisateur", - "common_back": "Retour", + "subscribe_dialog_login_button_back": "Retour", "subscribe_dialog_error_user_anonymous": "anonyme", "prefs_notifications_sound_no_sound": "Aucun son", "prefs_notifications_min_priority_title": "Priorité minimum", @@ -187,189 +187,5 @@ "prefs_users_edit_button": "Éditer l'utilisateur", "prefs_users_delete_button": "Supprimer l'utilisateur", "error_boundary_unsupported_indexeddb_title": "Navigation privée non prise en charge", - "publish_dialog_attached_file_remove": "Retirer le fichier joint", - "signup_form_password": "Mot de passe", - "signup_form_confirm_password": "Confirmation du mot de passe", - "signup_disabled": "L'inscription est désactivée", - "signup_error_username_taken": "L'identifiant {{username}} est déjà utilisé", - "signup_error_creation_limit_reached": "Limite de création de comptes atteinte", - "login_title": "Se connecter à son compte Ntfy", - "login_form_button_submit": "Connexion", - "login_link_signup": "S'inscrire", - "login_disabled": "La connection est désactivée", - "action_bar_account": "Compte", - "action_bar_profile_title": "Profil", - "action_bar_profile_settings": "Paramètres", - "action_bar_sign_in": "Connexion", - "action_bar_sign_up": "Inscription", - "nav_button_account": "Compte", - "signup_title": "Créer un compte Ntfy", - "signup_form_username": "Identifiant", - "signup_form_button_submit": "S'inscrire", - "signup_already_have_account": "Vous avez déjà un compte ? Connectez-vous !", - "action_bar_profile_logout": "Se déconnecter", - "signup_form_toggle_password_visibility": "Afficher le mot de passe", - "action_bar_change_display_name": "Changer le nom affiché", - "prefs_reservations_table_click_to_subscribe": "Cliquer pour s'abonner", - "account_tokens_table_cannot_delete_or_edit": "Impossible d'éditer ou de supprimer le jeton de la session actuelle", - "account_tokens_dialog_button_cancel": "Annuler", - "prefs_users_table_cannot_delete_or_edit": "Impossible de supprimer ou de modifier un utilisateur connecté", - "prefs_users_description_no_sync": "Les utilisateurs et les mots de passe ne sont pas synchronisés avec votre compte.", - "account_tokens_dialog_button_update": "Mettre à jour un jeton", - "nav_upgrade_banner_description": "Réservation de sujets, plus de messages et d'emails, et des pièces jointes plus larges", - "display_name_dialog_description": "Mettre un nom supplémentaire pour un sujet qui est affiché dans la liste des abonnements. Cela aide à identifier plus facilement les sujets ayant des noms compliqués.", - "account_usage_basis_ip_description": "Les statistiques d'utilisation et les limites pour ce compte sont basées sur votre adresse IP, donc elles peuvent être partagées avec d'autres utilisateurs. Les limites affichées plus haut sont approximativement basées sur les limites de débit existantes.", - "action_bar_reservation_add": "Réserver un sujet", - "action_bar_reservation_edit": "Changer la réservation", - "action_bar_reservation_delete": "Supprimer la réservation", - "action_bar_reservation_limit_reached": "Limite atteinte", - "nav_upgrade_banner_label": "Passer à ntfy Pro", - "display_name_dialog_title": "Changer le nom affiché", - "reserve_dialog_checkbox_label": "Réserver un sujet et en configurer l'accès", - "display_name_dialog_placeholder": "Nom affiché", - "subscribe_dialog_subscribe_button_generate_topic_name": "Générer un nom", - "subscribe_dialog_error_topic_already_reserved": "Sujet déjà réservé", - "account_basics_title": "Compte", - "account_basics_username_title": "Nom d'utilisateur", - "account_basics_username_description": "Hé, c'est toi ❤", - "account_basics_username_admin_tooltip": "Vous êtes Administrateur", - "account_basics_password_title": "Mot de passe", - "account_basics_password_description": "Changer le mot de passe de votre compte", - "account_basics_password_dialog_title": "Changer le mot de passe", - "account_basics_password_dialog_current_password_label": "Mot de passe actuel", - "account_basics_password_dialog_new_password_label": "Nouveau mot de passe", - "account_basics_password_dialog_confirm_password_label": "Confirmer le mot de passe", - "account_basics_password_dialog_button_submit": "Changer le mot de passe", - "account_basics_password_dialog_current_password_incorrect": "Mot de passe incorrect", - "account_usage_title": "Utilisation", - "account_usage_of_limit": "sur {{limit}}", - "account_usage_unlimited": "Illimité", - "account_usage_limits_reset_daily": "Les limites d'utilisation sont réinitialisées chaque jour à minuit (UTC)", - "account_basics_tier_title": "Type de compte", - "account_basics_tier_description": "Le niveau de puissance de votre compte", - "account_basics_tier_admin": "Administrateur", - "account_basics_tier_admin_suffix_with_tier": "(avec le tarif {{tier}})", - "account_basics_tier_admin_suffix_no_tier": "(pas de tarif)", - "account_basics_tier_free": "Gratuit", - "account_basics_tier_upgrade_button": "Passer à Pro", - "account_basics_tier_change_button": "Changer", - "account_basics_tier_paid_until": "Abonnement payé jusqu'à {{date}}, et va être automatiquement renouvelé", - "account_basics_tier_canceled_subscription": "Votre abonnement a été annulé et va être rétrogradé vers un compte gratuit le {{date}}.", - "account_basics_tier_manage_billing_button": "Gérer la facturation", - "account_usage_messages_title": "Messages publiés", - "account_usage_emails_title": "Emails envoyés", - "account_usage_reservations_title": "Sujets réservés", - "account_usage_reservations_none": "Pas de sujet réservé pour ce compte", - "account_usage_attachment_storage_title": "Stockage des pièces jointes", - "account_usage_attachment_storage_description": "{{filesize}} par fichier, supprimé après {{expiry}}", - "account_usage_cannot_create_portal_session": "Impossible d'ouvrir le portail de facturation", - "account_delete_title": "Supprimer le compte", - "account_delete_description": "Supprimer définitivement votre compte", - "account_basics_tier_basic": "Basique", - "account_delete_dialog_description": "Cela supprimera définitivement votre compte, ainsi que toutes les données qui sont stockées sur le serveur. Après suppression, votre nom d'utilisateur sera indisponible pendant 7 jours. Si vous voulez vraiment faire cela, veuillez le confirmer en mettant votre mot de passe dans le champ ci-dessous.", - "account_delete_dialog_label": "Mot de passe", - "account_delete_dialog_button_cancel": "Annuler", - "account_delete_dialog_button_submit": "Supprimer définitivement le compte", - "account_delete_dialog_billing_warning": "Supprimer votre compte annule aussi immédiatement votre facturation. Vous n'aurez plus accès à votre tableau de bord de facturation.", - "account_upgrade_dialog_title": "Changer le tarif du compte", - "account_upgrade_dialog_proration_info": "Facturation : Lors d'un changement entre un plan payant et un autre, la différence de prix sera créditée ou remboursée sur la prochaine facture. Vous ne recevrez pas d'autre facture avant la fin de la prochaine période de facturation.", - "account_upgrade_dialog_reservations_warning_other": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, veuillez supprimer au moins {{count}} sujets réservés. Vous pouvez supprimer des sujets réservés dans les Paramètres.", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} sujets réservés", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} messages journaliers", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} emails journaliers", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} par fichier", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} stockage total", - "account_upgrade_dialog_tier_selected_label": "Sélectionné", - "account_upgrade_dialog_tier_current_label": "Actuel", - "account_upgrade_dialog_button_cancel": "Annuler", - "account_upgrade_dialog_button_redirect_signup": "S'inscrire maintenant", - "account_upgrade_dialog_button_pay_now": "Payer maintenant et s'abonner", - "account_upgrade_dialog_button_cancel_subscription": "Annuler l'abonnement", - "account_upgrade_dialog_button_update_subscription": "Mettre à jour l'abonnement", - "account_tokens_title": "Jetons d'accès", - "account_tokens_table_token_header": "Jeton", - "account_tokens_table_label_header": "Étiquette", - "account_tokens_table_last_access_header": "Dernier accès", - "account_tokens_table_expires_header": "Expire", - "account_tokens_table_never_expires": "N'expire jamais", - "account_tokens_table_current_session": "Session de navigation actuelle", - "common_copy_to_clipboard": "Copier dans le presse-papier", - "account_tokens_table_copied_to_clipboard": "Jeton d'accès copié", - "account_tokens_table_create_token_button": "Créer un jeton d'accès", - "account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher", - "account_tokens_dialog_title_create": "Créer un jeton d'accès", - "account_tokens_dialog_title_edit": "Modifier le jeton d'accès", - "account_tokens_dialog_title_delete": "Supprimer le jeton d'accès", - "account_tokens_dialog_label": "Étiquette, par ex. Notifications Radarr", - "account_tokens_dialog_button_create": "Créer un jeton", - "account_tokens_dialog_expires_label": "Le jeton d'accès expire dans", - "account_tokens_dialog_expires_unchanged": "Laisser la date d'expiration inchangée", - "account_tokens_dialog_expires_x_hours": "Le jeton expire dans {{hours}} heures", - "account_tokens_dialog_expires_x_days": "Le jeton expire dans {{days}} jours", - "account_tokens_dialog_expires_never": "Le jeton n'expire jamais", - "account_tokens_delete_dialog_title": "Supprimer le jeton d'accès", - "account_tokens_delete_dialog_submit_button": "Supprimer définitivement le jeton", - "prefs_reservations_title": "Sujets réservés", - "prefs_reservations_limit_reached": "Vous avez atteint votre limite de réservation de sujets.", - "prefs_reservations_add_button": "Ajouter un sujet réservé", - "prefs_reservations_edit_button": "Modifier l'accès d'un sujet", - "prefs_reservations_delete_button": "Réinitialiser l'accès d'un sujet", - "prefs_reservations_table": "Tableau des sujets réservés", - "prefs_reservations_table_topic_header": "Sujet", - "prefs_reservations_table_access_header": "Accès", - "prefs_reservations_table_everyone_deny_all": "Seulement moi peut publier et m'abonner", - "prefs_reservations_table_everyone_read_only": "Je peux publier et m'abonner, tout le monde peut s'abonner", - "prefs_reservations_table_everyone_write_only": "Je peux publier et m'abonner, tout le monde peut publier", - "prefs_reservations_table_everyone_read_write": "Tout le monde peut publier et s'abonner", - "prefs_reservations_table_not_subscribed": "Pas abonné", - "prefs_reservations_dialog_title_add": "Réserver un sujet", - "prefs_reservations_dialog_title_edit": "Modifier un sujet réservé", - "prefs_reservations_dialog_title_delete": "Supprimé un sujet réservé", - "prefs_reservations_dialog_description": "Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs.", - "prefs_reservations_dialog_topic_label": "Sujet", - "prefs_reservations_dialog_access_label": "Accès", - "reservation_delete_dialog_description": "Supprimer un sujet réservé abandonne la propriété sur le sujet et permet aux autres de le réserver. Vous pouvez garder ou supprimer les messages et pièces jointes existantes.", - "reservation_delete_dialog_action_keep_title": "Garder les messages et pièces jointes mises en cache", - "reservation_delete_dialog_action_keep_description": "Les messages et pièces jointes qui sont dans le cache du serveur deviendront visibles publiquement pour les personnes ayant connaissance du nom du sujet.", - "reservation_delete_dialog_action_delete_title": "Supprimer les messages et pièces jointes mises en cache", - "reservation_delete_dialog_action_delete_description": "Les messages et pièces jointes mises en cache seront définitivement supprimées. Cette action ne peut pas être annulée.", - "reservation_delete_dialog_submit_button": "Supprimer un sujet réservé", - "alert_not_supported_context_description": "Les notifications ne sont supportées qu'en HTTPS. C'est une limitation de la Notifications API.", - "account_basics_tier_payment_overdue": "Votre paiement est en retard. Veuillez mettre à jour votre méthode de paiement, ou votre compte va bientôt être rétrogradé.", - "account_upgrade_dialog_cancel_warning": "Cela va annuler votre abonnement et rétrograder votre compte le {{date}}. Ce jour là, les sujets réservés ainsi que tous les messages dans le cache du serveur seront supprimés.", - "account_upgrade_dialog_reservations_warning_one": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, veuillez supprimer au moins un sujet réservé. Vous pouvez supprimer des sujets réservés dans les Paramètres.", - "account_tokens_description": "Utilisez des jetons d'accès lors de la publication ou de l'abonnement via l'API de ntfy, afin d'éviter d'envoyer vos identifiants de compte. Regardez la documentation pour en savoir plus.", - "account_tokens_delete_dialog_description": "Avant de supprimer un jeton d'accès, assurez-vous qu'aucune application ou script ne soit en train de l'utiliser. Cette action ne peut pas être annulée.", - "prefs_reservations_description": "Vous pouvez réserver les noms de sujet à usage personnel ici. Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs.", - "account_basics_tier_interval_yearly": "annuel", - "account_upgrade_dialog_interval_yearly": "Annuel", - "account_upgrade_dialog_interval_yearly_discount_save": "économisez {{discount}}%", - "account_upgrade_dialog_tier_features_no_reservations": "Aucun sujet(s) réservé(s)", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} par an. Prélevé mensuellement.", - "account_upgrade_dialog_billing_contact_website": "Pour des questions en rapport avec la facturation, se référer à notre site internet.", - "account_basics_tier_interval_monthly": "mensuel", - "account_upgrade_dialog_interval_monthly": "Mensuel", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "économisez jusqu'à {{discount}}%", - "account_upgrade_dialog_tier_price_per_month": "mois", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} prélevé annuellement. Économisez {{save}}.", - "account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous contacter directement.", - "publish_dialog_call_label": "Appel téléphonique", - "account_basics_phone_numbers_title": "Numéros de téléphone", - "account_basics_phone_numbers_dialog_description": "Pour utiliser la fonctionnalité de notification par appels, vous devez ajouter et vérifier au moins un numéro de téléphone. La vérification peut se faire par SMS ou appel téléphonique.", - "account_basics_phone_numbers_description": "Pour des notifications par appel téléphoniques", - "account_basics_phone_numbers_no_phone_numbers_yet": "Pas encore de numéros de téléphone", - "account_basics_phone_numbers_copied_to_clipboard": "Numéro de téléphone copié dans le presse-papier", - "account_basics_phone_numbers_dialog_title": "Ajouter un numéro de téléphone", - "account_basics_phone_numbers_dialog_number_label": "Numéro de téléphone", - "account_basics_phone_numbers_dialog_number_placeholder": "Ex : +33701020304", - "account_basics_phone_numbers_dialog_verify_button_sms": "Envoyer un SMS", - "account_basics_phone_numbers_dialog_verify_button_call": "Appelez moi", - "account_basics_phone_numbers_dialog_code_label": "Code de vérification", - "account_basics_phone_numbers_dialog_code_placeholder": "Ex : 123456", - "account_basics_phone_numbers_dialog_check_verification_button": "Code de confirmarion", - "account_basics_phone_numbers_dialog_channel_sms": "SMS", - "account_basics_phone_numbers_dialog_channel_call": "Appel", - "account_usage_calls_none": "Aucun appels téléphoniques ne peut être fait avec ce compte", - "publish_dialog_call_reset": "Supprimer les appels téléphoniques", - "publish_dialog_chip_call_label": "Appel téléphonique" + "publish_dialog_attached_file_remove": "Retirer le fichier joint" } diff --git a/web/public/static/langs/gl.json b/web/public/static/langs/gl.json deleted file mode 100644 index 0967ef4..0000000 --- a/web/public/static/langs/gl.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/web/public/static/langs/hu.json b/web/public/static/langs/hu.json index b52e3a4..975d8d9 100644 --- a/web/public/static/langs/hu.json +++ b/web/public/static/langs/hu.json @@ -84,7 +84,7 @@ "subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.", "subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi", "subscribe_dialog_login_password_label": "Jelszó", - "common_back": "Vissza", + "subscribe_dialog_login_button_back": "Vissza", "subscribe_dialog_login_button_login": "Belépés", "subscribe_dialog_error_user_anonymous": "névtelen", "subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése", diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json index 48fcda0..95f8535 100644 --- a/web/public/static/langs/id.json +++ b/web/public/static/langs/id.json @@ -116,7 +116,7 @@ "common_save": "Simpan", "prefs_appearance_title": "Tampilan", "subscribe_dialog_login_password_label": "Kata sandi", - "common_back": "Kembali", + "subscribe_dialog_login_button_back": "Kembali", "prefs_notifications_sound_title": "Suara notifikasi", "prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi", "prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi", @@ -187,199 +187,5 @@ "prefs_users_edit_button": "Edit pengguna", "prefs_users_delete_button": "Hapus pengguna", "error_boundary_unsupported_indexeddb_description": "Aplikasi web ntfy membutuhkan IndexedDB untuk berfungsi, dan peramban Anda tidak mendukung IndexedDB dalam mode penjelajahan pribadi.

Meskipun ini disayangkan, penggunaan aplikasi web ntfy juga tidak masuk akal di mode penjelajahan pribadi, karena semuanya disimpan di penyimpanan peramban. Anda dapat membaca lebih lanjut tentangnya di masalah GitHub ini, atau berbicara dengan kami di Discord atau Matrix.", - "error_boundary_unsupported_indexeddb_title": "Penjelajahan privat tidak didukung", - "signup_form_confirm_password": "Konfirmasi kata sandi", - "signup_form_button_submit": "Daftar", - "signup_form_toggle_password_visibility": "Alih keterlihatan kata sandi", - "signup_already_have_account": "Sudah punya akun? Masuk!", - "signup_disabled": "Pendaftaran dinonaktifkan", - "signup_error_username_taken": "Nama pengguna {{username}} telah digunakan", - "signup_error_creation_limit_reached": "Batasan pembuatan akun tercapai", - "login_title": "Masuk ke akun ntfy Anda", - "login_disabled": "Pemasukan dinonaktifkan", - "action_bar_account": "Akun", - "action_bar_change_display_name": "Ubah nama tampilan", - "action_bar_reservation_add": "Reservasi topik", - "action_bar_reservation_edit": "Ubah reservasi", - "action_bar_reservation_delete": "Hapus reservasi", - "action_bar_reservation_limit_reached": "Batasan tercapai", - "action_bar_profile_title": "Profil", - "action_bar_profile_settings": "Pengaturan", - "action_bar_profile_logout": "Keluar", - "nav_button_account": "Akun", - "display_name_dialog_placeholder": "Nama tampilan", - "reserve_dialog_checkbox_label": "Reservasi topik dan atur akses", - "nav_upgrade_banner_description": "Reservasikan topik, lebih banyak pesan & surel, dan lampiran lebih besar", - "signup_title": "Buat sebuah akun ntfy", - "signup_form_password": "Kata sandi", - "login_link_signup": "Daftar", - "action_bar_sign_up": "Daftar", - "signup_form_username": "Nama pengguna", - "login_form_button_submit": "Masuk", - "action_bar_sign_in": "Masuk", - "nav_upgrade_banner_label": "Tingkatkan ke ntfy Pro", - "alert_not_supported_context_description": "Notifikasi hanya didukung melalui HTTPS. Ini adalah batasan API Notifikasi.", - "display_name_dialog_title": "Ubah nama tampilan", - "display_name_dialog_description": "Tetapkan nama alternatif untuk sebuah topik yang ditampilkan di daftar langganan. Ini membantu mengidentifikasi topik dengan nama yang rumit dengan lebih mudah.", - "subscribe_dialog_error_topic_already_reserved": "Topik sudah direservasi", - "account_basics_username_title": "Nama pengguna", - "account_basics_username_admin_tooltip": "Anda adalah Admin", - "account_basics_password_title": "Kata sandi", - "account_basics_password_description": "Ubah kata sandi akun Anda", - "account_basics_password_dialog_title": "Ubah kata sandi", - "account_basics_password_dialog_current_password_label": "Kata sandi saat ini", - "account_basics_password_dialog_confirm_password_label": "Konfirmasi kata sandi", - "account_basics_password_dialog_button_submit": "Ubah kata sandi", - "account_basics_password_dialog_current_password_incorrect": "Kata sandi salah", - "account_usage_title": "Penggunaan", - "account_usage_of_limit": "dari {{limit}}", - "account_usage_unlimited": "Tidak terbatas", - "account_usage_limits_reset_daily": "Batasan penggunaan diatur ulang setiap hari di tengah malam (UTC)", - "account_basics_tier_title": "Jenis akun", - "account_basics_tier_description": "Tingkat daya akun Anda", - "account_basics_tier_admin_suffix_no_tier": "(tidak ada peringkat)", - "account_basics_tier_basic": "Dasaran", - "account_basics_tier_change_button": "Ubah", - "account_basics_tier_paid_until": "Langganan dibayar sampai {{date}}, dan akan dibayar secara otomatis", - "account_basics_tier_canceled_subscription": "Langganan Anda dibatalkan dan akan diturunkan ke akun gratis pada {{date}}.", - "account_usage_messages_title": "Pesan terkirim", - "account_usage_emails_title": "Surel terkirim", - "account_usage_reservations_title": "Topik yang telah direservasi", - "account_usage_reservations_none": "Tidak ada topik yang telah direservasi untuk akun ini", - "account_usage_attachment_storage_title": "Penyimpanan lampiran", - "account_usage_attachment_storage_description": "{{filesize}} per berkas, dihapus setelah {{expiry}}", - "account_delete_title": "Hapus akun", - "account_delete_description": "Hapus akun Anda secara permanen", - "account_delete_dialog_label": "Kata sandi", - "account_delete_dialog_button_cancel": "Batal", - "account_delete_dialog_button_submit": "Hapus akun secara permanen", - "account_usage_cannot_create_portal_session": "Tidak dapat membuka portal tagihan", - "account_delete_dialog_billing_warning": "Menghapus akun Anda juga membatalkan tagihan langganan dengan segera. Anda tidak akan memiliki akses lagi ke dasbor tagihan.", - "account_upgrade_dialog_title": "Ubah peringkat akun", - "account_upgrade_dialog_proration_info": "Prorasi: Saat melakukan upgrade antar paket berbayar, selisih harga akan langsung dibebankan ke. Saat menurunkan ke tingkat yang lebih rendah, saldo akan digunakan untuk membayar periode penagihan di masa mendatang.", - "account_upgrade_dialog_reservations_warning_other": "Peringkat yang dipilih memperbolehkan lebih sedikit reservasi topik daripada peringkat Anda saat ini. Sebelum mengubah peringkat Anda, silakan menghapus setidaknya {{count}} reservasi. Anda dapat menghapus reservasi di Pengaturan.", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} topik yang telah direservasi", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} pesan harian", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} surel harian", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per berkas", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} jumlah penyimpanan", - "account_upgrade_dialog_tier_selected_label": "Dipilih", - "account_upgrade_dialog_tier_current_label": "Saat ini", - "account_upgrade_dialog_button_cancel": "Batal", - "account_upgrade_dialog_button_redirect_signup": "Daftar sekarang", - "account_upgrade_dialog_button_pay_now": "Bayar sekaramg dan berlangganan", - "account_upgrade_dialog_button_cancel_subscription": "Batalkan langganan", - "account_upgrade_dialog_button_update_subscription": "Perbarui langganan", - "account_tokens_title": "Token akses", - "account_tokens_description": "Gunakan token akses saat mengirim dan berlangganan melalui API ntfy, sehingga Anda tidak perlu mengirimkan kredensial akun Anda. Lihat dokumentasi untuk mempelajari lebih lanjut.", - "account_tokens_table_token_header": "Token", - "account_tokens_table_label_header": "Label", - "account_tokens_table_last_access_header": "Akses terakhir", - "account_tokens_table_expires_header": "Kedaluwarsa", - "account_tokens_table_never_expires": "Tidak pernah kedaluwarsa", - "account_tokens_table_current_session": "Sesi peramban saat ini", - "common_copy_to_clipboard": "Salin ke papan klip", - "account_tokens_table_copied_to_clipboard": "Token akses disalin", - "account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini", - "account_tokens_table_create_token_button": "Buat token akses", - "account_tokens_dialog_expires_unchanged": "Tinggalkan tanggal kedaluwarsa tidak terganti", - "account_tokens_dialog_expires_x_hours": "Token kedaluwarsa dalam {{hours}} jam", - "account_tokens_dialog_expires_x_days": "Token kedaluwarsa dalam {{days}} hari", - "account_tokens_dialog_expires_never": "Token tidak pernah kedaluwarsa", - "account_tokens_delete_dialog_title": "Hapus token akses", - "account_tokens_delete_dialog_description": "Sebelum menghapus sebuah token akses, pastikan bahwa tidak ada aplikasi atau skrip yang sedang menggunakannya secara aktif. Tindakan ini tidak dapat diurungkan.", - "account_tokens_delete_dialog_submit_button": "Hapus token secara permanan", - "prefs_reservations_title": "Topik yang direservasi", - "reservation_delete_dialog_action_keep_title": "Jaga tembolok pesan dan lampiran", - "reservation_delete_dialog_action_keep_description": "Tembolok pesan dan lampiran yang berada di server akan terlihat secara publik untuk orang-orang dengan pengetahuan nama topik.", - "reservation_delete_dialog_action_delete_title": "Hapus tembolok pesan dan lampiran", - "reservation_delete_dialog_action_delete_description": "Tembolok pesan dan lampiran akan dihapus secara permanen. Tindakan ini tidak dapat diurungkan.", - "reservation_delete_dialog_submit_button": "Hapus reservasi", - "prefs_reservations_table_everyone_read_only": "Saya dapat mengirim dan berlangganan, semuanya dapat berlangganan", - "prefs_reservations_dialog_title_edit": "Sunting reservasi topik", - "subscribe_dialog_subscribe_button_generate_topic_name": "Buat nama", - "account_basics_title": "Akun", - "account_basics_tier_admin_suffix_with_tier": "(dengan peringkat {{tier}})", - "account_basics_tier_free": "Gratis", - "account_tokens_dialog_expires_label": "Token akses kedaluwarsa dalam", - "account_basics_username_description": "Hei, itu Anda ❤", - "account_basics_password_dialog_new_password_label": "Kata sandi baru", - "account_basics_tier_admin": "Admin", - "account_basics_tier_upgrade_button": "Tingkatkan ke Pro", - "account_basics_tier_payment_overdue": "Pembayaran Anda telah jatuh tempo. Mohon perbarui metode pembayaran Anda, atau akun Anda akan segera diturunkan.", - "account_basics_tier_manage_billing_button": "Kelola pembayaran", - "account_tokens_dialog_title_delete": "Hapus token akses", - "account_usage_basis_ip_description": "Statistik dan batasan pengguna untuk akun ini berdasarkan alamat IP Anda, sehingga mereka mungkin terbagi dengan pengguna lain. Batasan yang ditampilkan di atas adalah perkiraan berdasarkan batas tarif yang sudah ada.", - "account_delete_dialog_description": "Ini akan menghapus akun Anda secara permanen, termasuk semua data yang telah disimpan di server ini. Setelah penghapusan, nama pengguna Anda akan tidak tersedia selama 7 hari. Jika Anda ingin melanjutkan, silakan mengonfirmasi dengan kata sandi Anda di kotak bawah.", - "account_upgrade_dialog_cancel_warning": "Ini akan membatalkan langganan Anda, dan menurunkan akun Anda pada tanggal {{date}}. Pada tanggal itu, reservasi topik maupun tembolok pesan di server akan dihapus.", - "prefs_reservations_table_everyone_write_only": "Saya dapat mengirim dan berlangganan, semuanya dapat mengirim", - "account_tokens_table_last_origin_tooltip": "Dari alamat IP {{ip}}, klik untuk melihat", - "account_tokens_dialog_label": "Label, mis. notifikasi Radarr", - "account_tokens_dialog_button_create": "Buat token", - "prefs_reservations_description": "Anda dapat mereservasi nama topik untuk penggunaan pribadi di sini. Mereservasikan sebuah topik memberikan Anda kemilikan pada topik, dan memungkinkan Anda untuk mendefinisikan perizinan akses untuk pengguna lain melalui topik.", - "account_upgrade_dialog_reservations_warning_one": "Peringkat yang dipilih memperbolehkan lebih sedikit reservasi topik daripada peringkat Anda saat ini. Sebelum mengubah peringkat Anda, silakan menghapus setidaknya satu reservasi. Anda dapat menghapus reservasi di Pengaturan.", - "account_tokens_dialog_button_cancel": "Batal", - "account_tokens_dialog_title_create": "Buat token akses", - "account_tokens_dialog_title_edit": "Sunting token akses", - "account_tokens_dialog_button_update": "Perbarui token", - "prefs_reservations_add_button": "Tambahkan reservasi topik", - "prefs_reservations_table": "Tabel topik yang telah direservasi", - "prefs_reservations_table_topic_header": "Topik", - "prefs_users_table_cannot_delete_or_edit": "Tidak dapat menghapus atau menyunting pengguna yang telah masuk", - "prefs_reservations_table_everyone_deny_all": "Hanya saya yang dapat mengirim dan berlangganan", - "prefs_reservations_table_everyone_read_write": "Semuanya dapat mengirim dan berlangganan", - "prefs_users_description_no_sync": "Pengguna dan kata sandi tidak disinkronkan ke akun Anda.", - "prefs_reservations_limit_reached": "Anda telah mencapai batasan reservasi topik.", - "prefs_reservations_edit_button": "Sunting akses topik", - "prefs_reservations_table_click_to_subscribe": "Klik untuk berlangganan", - "prefs_reservations_delete_button": "Atur ulang akses topik", - "prefs_reservations_table_access_header": "Akses", - "prefs_reservations_dialog_title_add": "Reservasi topik", - "prefs_reservations_dialog_title_delete": "Hapus reservasi topik", - "prefs_reservations_table_not_subscribed": "Tidak berlangganan", - "prefs_reservations_dialog_description": "Mereservasikan sebuah topik memberikan Anda kemilikan pada topik, dan memungkinkan Anda untuk mendefinisikan perizinan akses untuk pengguna lain melalui topik.", - "prefs_reservations_dialog_topic_label": "Topik", - "prefs_reservations_dialog_access_label": "Akses", - "reservation_delete_dialog_description": "Menghapus sebuah reservasi menghapus kemilikan pada topik, dan memperbolehkan orang-orang lain untuk mereservasinya.", - "account_upgrade_dialog_interval_yearly": "Setiap tahun", - "account_upgrade_dialog_tier_price_billed_yearly": "Ditagih {{price}} setiap tahun. Hemat {{save}}.", - "account_upgrade_dialog_interval_yearly_discount_save": "hemat {{discount}}%", - "account_upgrade_dialog_interval_monthly": "Setiap bulan", - "account_basics_tier_interval_monthly": "setiap bulan", - "account_basics_tier_interval_yearly": "setiap tahun", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "hemat sampai {{discount}}%", - "account_upgrade_dialog_tier_features_no_reservations": "Tidak ada topik yang direservasi", - "account_upgrade_dialog_tier_price_per_month": "bulan", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per bulan. Ditagih setiap bulan.", - "account_upgrade_dialog_billing_contact_email": "Untuk pertanyaan penagihan, silakan hubungi kami secara langsung.", - "account_upgrade_dialog_billing_contact_website": "Untuk pertanyaan penagihan, silakan menuju ke situs web kami.", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} topik yang direservasi", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} surel harian", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} pesan harian", - "publish_dialog_call_label": "Panggilan telepon", - "publish_dialog_call_placeholder": "Nomor telepon untuk dipanggil dengan pesan, mis. +622223334444, atau 'yes'", - "account_basics_phone_numbers_title": "Nomor telepon", - "account_basics_phone_numbers_dialog_description": "Untuk menggunakan fitur notifikasi telepon, Anda perlu menambahkan dan memverifikasi setidaknya satu nomor telepon. Verifikasi dapat dilakukan melalui SMS atau panggilan telepon.", - "account_basics_phone_numbers_no_phone_numbers_yet": "Belum ada nomor telepon", - "account_basics_phone_numbers_dialog_title": "Tambahkan nomor telepon", - "account_basics_phone_numbers_dialog_number_label": "Nomor telepon", - "account_basics_phone_numbers_dialog_number_placeholder": "mis. +62222333444", - "account_basics_phone_numbers_dialog_verify_button_sms": "Kirim SMS", - "account_basics_phone_numbers_dialog_channel_call": "Panggil", - "account_usage_calls_title": "Panggilan telepon dilakukan", - "account_usage_calls_none": "Tidak ada panggilan telepon yang dapat dilakukan dengan akun ini", - "account_upgrade_dialog_tier_features_calls_other": "{{calls}} panggilan telepon harian", - "publish_dialog_call_reset": "Hapus panggilan telepon", - "account_basics_phone_numbers_description": "Untuk notifikasi panggilan telepon", - "account_basics_phone_numbers_copied_to_clipboard": "Nomor telepon disalin ke papan klip", - "publish_dialog_chip_call_label": "Panggilan telepon", - "account_basics_phone_numbers_dialog_verify_button_call": "Panggil saya", - "account_basics_phone_numbers_dialog_code_placeholder": "mis. 123456", - "account_basics_phone_numbers_dialog_check_verification_button": "Konfirmasi kode", - "account_basics_phone_numbers_dialog_channel_sms": "SMS", - "account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian", - "account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon", - "account_basics_phone_numbers_dialog_code_label": "Kode verifikasi", - "publish_dialog_call_item": "Panggil nomor telepon {{number}}", - "publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi" + "error_boundary_unsupported_indexeddb_title": "Penjelajahan privat tidak didukung" } diff --git a/web/public/static/langs/it.json b/web/public/static/langs/it.json index 95c4b5b..3dc40d5 100644 --- a/web/public/static/langs/it.json +++ b/web/public/static/langs/it.json @@ -178,7 +178,7 @@ "prefs_notifications_sound_play": "Riproduci il suono selezionato", "prefs_notifications_min_priority_title": "Priorità minima", "subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.", - "common_back": "Indietro", + "subscribe_dialog_login_button_back": "Indietro", "subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato", "prefs_notifications_title": "Notifiche", "prefs_notifications_delete_after_title": "Elimina le notifiche", @@ -187,77 +187,5 @@ "prefs_notifications_delete_after_one_week": "Dopo una settimana", "prefs_notifications_delete_after_one_month": "Dopo un mese", "prefs_notifications_delete_after_three_hours_description": "Le notifiche vengono eliminate automaticamente dopo tre ore", - "error_boundary_unsupported_indexeddb_description": "L'app web ntfy ha bisogno di IndexedDB per funzionare e il tuo browser non supporta IndexedDB in modalità di navigazione privata.

Anche se questo è un peccato, non ha molto senso usare il web ntfy app in modalità di navigazione privata comunque, perché tutto è archiviato nella memoria del browser. Puoi leggere di più a riguardo in questo numero di GitHub o parlarci su Discord o Matrix.", - "nav_upgrade_banner_label": "Passa alla versione Pro di ntfy", - "alert_not_supported_context_description": "Le Notificche sono supportate solo tramite HTTPS. Questa è una limitazione delle Notifications API.", - "account_basics_password_dialog_new_password_label": "Nuova password", - "action_bar_profile_logout": "Esci", - "account_basics_tier_interval_monthly": "mensile", - "account_basics_tier_interval_yearly": "annuale", - "account_basics_tier_upgrade_button": "Passa alla versione Pro", - "account_basics_tier_change_button": "Cambia", - "account_basics_tier_paid_until": "Abbonamento pagato fino a {{data}}, e si rinnoverà automaticamente", - "account_basics_tier_payment_overdue": "Il pagamento è scaduto. La preghiamo di aggiornare il suo metodo di pagamento, altrimenti il suo account verrà presto declassato.", - "account_basics_tier_canceled_subscription": "L'abbonamento è stato annullato e sarà declassato ad account gratuito a partire dalla {{data}}.", - "account_basics_tier_manage_billing_button": "Gestire la fatturazione", - "account_usage_messages_title": "Messaggi pubblicati", - "account_usage_reservations_title": "Argomenti riservati", - "account_usage_reservations_none": "Non ci sono argomenti riservati per questo account", - "signup_form_toggle_password_visibility": "Imposta la visibilità della password", - "signup_already_have_account": "Hai già un account? Accedi!", - "signup_disabled": "Registrazione disabilitata", - "signup_title": "Crea un account ntfy", - "signup_form_username": "Nome utente", - "signup_form_password": "Password", - "signup_form_confirm_password": "Conferma password", - "signup_form_button_submit": "Registrazione", - "signup_error_username_taken": "Il nome utente {{username}} è già utilizzato", - "signup_error_creation_limit_reached": "Il limite per la creazione di account è stato raggiunto", - "login_title": "Accedi al tuo account ntfy", - "login_form_button_submit": "Accedi", - "login_link_signup": "Registrati", - "login_disabled": "L'accesso è disabilitato", - "action_bar_account": "Account", - "action_bar_change_display_name": "Cambia il nome da visualizzare", - "action_bar_reservation_limit_reached": "Limite raggiunto", - "action_bar_profile_title": "Profilo", - "action_bar_profile_settings": "Impostazioni", - "action_bar_reservation_add": "Riserva un argomento", - "action_bar_reservation_edit": "Modifica l'argomento riservato", - "action_bar_reservation_delete": "Rimuovi l'argomento riservato", - "action_bar_sign_in": "Accedi", - "action_bar_sign_up": "Registrati", - "nav_button_account": "Account", - "nav_upgrade_banner_description": "Riserva argomenti, più messaggi ed e-mail e allegati più grandi", - "display_name_dialog_description": "Imposta un nome alternativo per un argomento che viene visualizzato nell'elenco delle sottoscrizioni. Questo aiuta a identificare più facilmente gli argomenti con nomi complicati.", - "display_name_dialog_title": "Cambia il nome visualizzato", - "display_name_dialog_placeholder": "Nome visualizzato", - "reserve_dialog_checkbox_label": "Riserva un argomento e configura l'accesso", - "subscribe_dialog_subscribe_button_generate_topic_name": "Genera un nome", - "subscribe_dialog_error_topic_already_reserved": "Argomento già in uso", - "account_basics_title": "Account", - "account_basics_username_title": "Nome utente", - "account_basics_username_admin_tooltip": "Sei Amministratore", - "account_basics_password_title": "Password", - "account_basics_password_description": "Cambia la password del tuo account", - "account_basics_password_dialog_title": "Cambia la password", - "account_basics_password_dialog_current_password_label": "Password attuale", - "account_basics_password_dialog_confirm_password_label": "Conferma la password", - "account_basics_password_dialog_button_submit": "Cambia la password", - "account_basics_password_dialog_current_password_incorrect": "Password errata", - "account_usage_title": "Utilizzo", - "account_usage_of_limit": "di {{limit}}", - "account_usage_unlimited": "Illimitato", - "account_usage_limits_reset_daily": "I limiti di utilizzo vengono azzerati ogni giorno a mezzanotte (orario UTC)", - "account_basics_tier_title": "Tipo di account", - "account_basics_tier_description": "Permessi del tuo account", - "account_basics_tier_admin": "Amministratore", - "account_basics_tier_admin_suffix_with_tier": "(con livello {{tier}})", - "account_basics_tier_admin_suffix_no_tier": "(nessun livello)", - "account_basics_tier_basic": "Base", - "account_basics_tier_free": "Gratuito", - "account_usage_emails_title": "Email inviate", - "account_usage_cannot_create_portal_session": "Impossibile aprire il portale di pagamento", - "account_delete_title": "Elimina account", - "account_basics_username_description": "Hey, sei tu ❤" + "error_boundary_unsupported_indexeddb_description": "L'app web ntfy ha bisogno di IndexedDB per funzionare e il tuo browser non supporta IndexedDB in modalità di navigazione privata.

Anche se questo è un peccato, non ha molto senso usare il web ntfy app in modalità di navigazione privata comunque, perché tutto è archiviato nella memoria del browser. Puoi leggere di più a riguardo in questo numero di GitHub o parlarci su Discord o Matrix." } diff --git a/web/public/static/langs/ja.json b/web/public/static/langs/ja.json index 9c68679..3978478 100644 --- a/web/public/static/langs/ja.json +++ b/web/public/static/langs/ja.json @@ -20,7 +20,7 @@ "subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。", "subscribe_dialog_login_username_label": "ユーザー名, 例) phil", "subscribe_dialog_login_password_label": "パスワード", - "common_back": "戻る", + "subscribe_dialog_login_button_back": "戻る", "subscribe_dialog_login_button_login": "ログイン", "prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上", "prefs_notifications_min_priority_max_only": "優先度最高のみ", @@ -187,174 +187,5 @@ "prefs_notifications_sound_play": "選択されたサウンドを再生", "prefs_users_table": "ユーザー一覧", "prefs_users_delete_button": "ユーザーを削除", - "error_boundary_unsupported_indexeddb_title": "プライベートブラウジングはサポートされていません", - "signup_form_username": "ユーザー名", - "signup_form_password": "パスワード", - "signup_form_confirm_password": "パスワードを確認", - "signup_already_have_account": "アカウントをお持ちならサインイン", - "signup_disabled": "サインアップは無効化されています", - "signup_error_creation_limit_reached": "アカウント作成制限に達しました", - "login_title": "あなたのntfyアカウントにサインイン", - "login_link_signup": "サインアップ", - "login_disabled": "ログインは無効化されています", - "action_bar_account": "アカウント", - "action_bar_change_display_name": "表示名を変更する", - "action_bar_reservation_add": "トピックを予約する", - "action_bar_reservation_edit": "予約を編集する", - "action_bar_reservation_limit_reached": "制限に達しました", - "action_bar_profile_title": "プロファイル", - "action_bar_profile_settings": "設定", - "action_bar_profile_logout": "ログアウト", - "action_bar_sign_in": "サインイン", - "action_bar_sign_up": "サインアップ", - "nav_button_account": "アカウント", - "nav_upgrade_banner_label": "ntfy Proにアップグレード", - "display_name_dialog_title": "表示名を変更", - "display_name_dialog_placeholder": "表示名", - "signup_form_button_submit": "サインアップ", - "signup_form_toggle_password_visibility": "パスワードを表示/非表示", - "signup_title": "ntfyアカウントを作成する", - "login_form_button_submit": "サインイン", - "alert_not_supported_context_description": "通知はHTTPSのみサポートされています。これはNotifications APIの制限によるものです。", - "nav_upgrade_banner_description": "トピックを予約、より多くのメッセージとメール、より大きい添付ファイル", - "signup_error_username_taken": "ユーザー名 {{username}} は既に使用されています", - "action_bar_reservation_delete": "予約を削除する", - "display_name_dialog_description": "購読リストに表示されるトピックの別名を設定して、複雑な名前のトピックの識別を容易にします。", - "reserve_dialog_checkbox_label": "トピックを保存してアクセスを編集", - "subscribe_dialog_subscribe_button_generate_topic_name": "名前を生成", - "subscribe_dialog_error_topic_already_reserved": "このトピックは予約済みです", - "account_basics_title": "アカウント", - "account_basics_tier_description": "アカウントのパワーレベル", - "account_basics_tier_admin": "管理者", - "account_basics_tier_admin_suffix_with_tier": "(ティア {{tier}})", - "account_basics_tier_free": "無料", - "account_usage_attachment_storage_description": "1ファイルあたり{{filesize}}、{{expiry}}を過ぎると削除", - "account_usage_basis_ip_description": "アカウントの使用量統計および制限はあなたのIPアドレスに基づいているため、他のユーザーと共有される可能性があります。上記制限は既存のレート制限に基づく概算値です。", - "account_usage_cannot_create_portal_session": "支払いポータルを開けませんでした", - "account_delete_title": "アカウントを削除", - "account_delete_description": "アカウントを永久的に削除", - "account_delete_dialog_description": "サーバーに保存されている全てのデータを含むあなたのアカウント情報を削除します。削除後、あなたのユーザー名は7日間利用できません。もし本当に先に進めたい場合、下の入力欄にパスワードを入力して確認して下さい。", - "account_delete_dialog_label": "パスワード", - "account_delete_dialog_button_cancel": "キャンセル", - "account_delete_dialog_button_submit": "永久的にアカウントを削除", - "account_delete_dialog_billing_warning": "アカウントを削除するとサブスクリプション支払いも即時キャンセルされます。支払いダッシュボードにもアクセスできなくなります。", - "account_upgrade_dialog_title": "アカウントティアを変更", - "account_upgrade_dialog_cancel_warning": "これによりサブスクリプションをキャンセルし{{date}}にアカウントをダウングレードします。同日、トピック予約およびサーバーにキャッシュされたメッセージは削除されます。", - "account_upgrade_dialog_proration_info": "追記。有料プランをアップグレードする場合、価格差は即座に請求されます。ダウングレードする場合、差額は次の請求期間の支払いに利用されます。", - "account_upgrade_dialog_tier_features_reservations_other": "予約のトピック{{reservations}}件", - "account_upgrade_dialog_tier_features_emails_other": "日次メール{{emails}}件", - "account_upgrade_dialog_tier_features_messages_other": "日次メッセージ{{messages}}件", - "account_upgrade_dialog_tier_selected_label": "選択", - "account_upgrade_dialog_tier_current_label": "現在", - "account_upgrade_dialog_button_cancel": "キャンセル", - "account_upgrade_dialog_button_redirect_signup": "サインアップ", - "account_upgrade_dialog_button_pay_now": "支払いしてサブスクライブする", - "account_upgrade_dialog_button_cancel_subscription": "サブスクリプションをキャンセル", - "account_upgrade_dialog_button_update_subscription": "サブスクリプションを更新", - "account_tokens_description": "ntfy APIで発行または購読する際にアクセストークンを使うことで、アカウント認証情報を送信する必要がなくなります。詳細はドキュメントを確認して下さい。", - "account_tokens_table_token_header": "トークン", - "account_tokens_table_label_header": "ラベル", - "account_tokens_table_last_access_header": "最終アクセス", - "account_tokens_table_expires_header": "期限", - "account_tokens_table_never_expires": "無期限", - "account_tokens_table_current_session": "現在のブラウザセッション", - "common_copy_to_clipboard": "クリップボードにコピー", - "account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました", - "account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません", - "account_tokens_table_create_token_button": "アクセストークンを生成", - "account_tokens_table_last_origin_tooltip": "IPアドレス {{ip}} から、クリックして参照", - "account_tokens_dialog_title_create": "アクセストークンを生成", - "account_tokens_dialog_title_edit": "アクセストークンを編集", - "account_tokens_dialog_title_delete": "アクセストークンを削除", - "account_tokens_dialog_label": "ラベル、例:Radarr通知", - "account_tokens_dialog_button_create": "トークンを生成", - "account_tokens_dialog_button_update": "トークンを更新", - "account_tokens_dialog_button_cancel": "キャンセル", - "account_tokens_dialog_expires_label": "アクセストークン有効期限", - "account_tokens_dialog_expires_unchanged": "有効期限を変更しない", - "account_tokens_dialog_expires_x_hours": "トークンは {{hours}} 時間後に失効します", - "account_tokens_dialog_expires_x_days": "トークンは {{days}} 日後に失効します", - "account_tokens_dialog_expires_never": "トークン失効なし", - "account_tokens_delete_dialog_title": "アクセストークンを削除", - "account_tokens_delete_dialog_submit_button": "トークンを永久削除", - "prefs_users_description_no_sync": "ユーザー名とパスワードはアカウントと同期されません。", - "prefs_users_table_cannot_delete_or_edit": "ログインしているユーザーは削除または編集できません", - "prefs_reservations_title": "予約されたトピック", - "prefs_reservations_description": "ここでトピック名を個人利用の為に予約する事ができます。トピックを予約する事でそのトピックの所有権が付与され、他のユーザーにアクセス権を付与する事ができるようになります。", - "prefs_reservations_add_button": "予約トピックを追加する", - "prefs_reservations_edit_button": "トピックへのアクセスを編集する", - "prefs_reservations_delete_button": "トピックへのアクセスをリセットする", - "prefs_reservations_table": "予約トピックの一覧", - "prefs_reservations_table_topic_header": "トピック", - "prefs_reservations_table_everyone_deny_all": "自分のみ発行と購読が可能", - "prefs_reservations_table_everyone_read_only": "自分は発行と購読が可能、誰でも購読可能", - "prefs_reservations_table_everyone_write_only": "自分は発行と購読可能、誰でも発行可能", - "prefs_reservations_table_everyone_read_write": "誰でも発行と購読が可能", - "prefs_reservations_table_not_subscribed": "購読されていません", - "prefs_reservations_table_click_to_subscribe": "クリックして購読", - "prefs_reservations_dialog_title_edit": "予約トピックを編集", - "prefs_reservations_dialog_title_delete": "トピック予約を削除", - "prefs_reservations_dialog_topic_label": "トピック", - "prefs_reservations_dialog_access_label": "アクセス", - "reservation_delete_dialog_action_keep_title": "キャッシュされたメッセージと添付ファイルを保持する", - "reservation_delete_dialog_action_keep_description": "サーバーにキャッシュされたメッセージと添付ファイルは公開されてトピック名を知っている人が閲覧できるようになります。", - "reservation_delete_dialog_action_delete_title": "キャッシュされたメッセージと添付ファイルを削除する", - "reservation_delete_dialog_action_delete_description": "キャッシュされたメッセージと添付ファイルは永久的に削除されます。この操作は元に戻せません。", - "account_basics_username_admin_tooltip": "あなたは管理者です", - "account_basics_password_title": "パスワード", - "account_basics_password_dialog_current_password_label": "現在のパスワード", - "account_usage_limits_reset_daily": "使用量制限は世界協定時 (UTC) の深夜に毎日リセットされます", - "account_basics_tier_basic": "ベーシック", - "account_basics_tier_paid_until": "サブスクリプションは{{date}}まで有効で、自動更新されます", - "account_basics_username_title": "ユーザー名", - "account_basics_username_description": "あなたのお名前です ❤", - "account_basics_password_description": "アカウントパスワードを変更", - "account_basics_password_dialog_title": "パスワード変更", - "account_basics_password_dialog_confirm_password_label": "パスワードを確認", - "account_basics_password_dialog_current_password_incorrect": "パスワードが異なります", - "account_usage_of_limit": ": {{limit}}", - "account_usage_unlimited": "無制限", - "account_basics_tier_upgrade_button": "プロにアップグレード", - "account_basics_tier_manage_billing_button": "支払い方法を管理", - "account_basics_password_dialog_new_password_label": "新しいパスワード", - "account_basics_password_dialog_button_submit": "パスワードを変更", - "account_usage_title": "使用量", - "account_basics_tier_title": "アカウントタイプ", - "account_basics_tier_admin_suffix_no_tier": "(ティアなし)", - "account_basics_tier_change_button": "変更", - "account_basics_tier_payment_overdue": "支払期限を過ぎています。支払い方法を更新しないと、近日中にアカウントはダウングレードされます。", - "account_basics_tier_canceled_subscription": "あなたのサブスクリプションはキャンセルされ{{date}}に無料アカウントにダウングレードされます。", - "account_usage_messages_title": "発行されたメッセージ", - "account_usage_reservations_none": "このアカウントで予約されたトピックはありません", - "account_usage_attachment_storage_title": "添付ストレージ", - "account_usage_emails_title": "送信済みメール", - "account_upgrade_dialog_reservations_warning_one": "選択されたティアは、現在のティアよりも少ない予約トピックを利用できます。ティアを変更する前に、少なくとも1つの予約を削除してください。予約の削除は、設定で行うことができます。", - "account_usage_reservations_title": "予約されたトピック", - "account_upgrade_dialog_reservations_warning_other": "選択されたティアは、現在のティアよりも少ない予約トピックを利用できます。ティアを変更する前に、少なくとも{{count}}個の予約を削除してください。予約の削除は、設定で行うことができます。", - "account_tokens_delete_dialog_description": "アクセストークンを削除する前に、アプリやスクリプトが利用中でないか確認して下さい。この操作は元に戻せません。", - "account_upgrade_dialog_tier_features_attachment_file_size": "1ファイルあたり{{filesize}}", - "account_upgrade_dialog_tier_features_attachment_total_size": "総ストレージ{{totalsize}}", - "account_tokens_title": "アクセストークン", - "prefs_reservations_limit_reached": "予約トピック数の上限に達しました。", - "prefs_reservations_table_access_header": "アクセス", - "prefs_reservations_dialog_title_add": "トピックを予約", - "prefs_reservations_dialog_description": "トピックを予約する事でそのトピックの所有権が付与され、他のユーザーにアクセス権を付与する事ができるようになります。", - "reservation_delete_dialog_description": "予約を削除するとトピックの所有権を失い、他の人が予約できるようになります。既存のメッセージや添付ファイルは保持または削除することができます。", - "reservation_delete_dialog_submit_button": "予約を削除", - "account_basics_tier_interval_monthly": "毎月", - "account_upgrade_dialog_interval_monthly": "毎月", - "account_upgrade_dialog_interval_yearly": "毎年", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "最大{{discount}}%節約", - "account_upgrade_dialog_tier_features_no_reservations": "予約トピックなし", - "account_upgrade_dialog_billing_contact_email": "支払いについての問い合わせは、直接お問い合わせください。", - "account_upgrade_dialog_interval_yearly_discount_save": "{{discount}}%節約", - "account_basics_tier_interval_yearly": "毎年", - "account_upgrade_dialog_tier_price_per_month": "月", - "account_upgrade_dialog_tier_price_billed_monthly": "年間{{price}}。月毎の支払い。", - "account_upgrade_dialog_tier_price_billed_yearly": "年間{{price}}の支払い。{{save}}節約。", - "account_upgrade_dialog_billing_contact_website": "支払いに関する質問は、ウェブサイトを参照して下さい。", - "account_upgrade_dialog_tier_features_messages_one": "毎日 {{messages}} メッセージ", - "account_upgrade_dialog_tier_features_reservations_one": "予約済みトピック {{reservations}} 件", - "account_upgrade_dialog_tier_features_emails_one": "毎日メール {{emails}} 件", - "publish_dialog_call_label": "電話" + "error_boundary_unsupported_indexeddb_title": "プライベートブラウジングはサポートされていません" } diff --git a/web/public/static/langs/ko.json b/web/public/static/langs/ko.json index 2e46c7a..67c3128 100644 --- a/web/public/static/langs/ko.json +++ b/web/public/static/langs/ko.json @@ -93,7 +93,7 @@ "subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다", "subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil", "subscribe_dialog_login_password_label": "비밀번호", - "common_back": "뒤로가기", + "subscribe_dialog_login_button_back": "뒤로가기", "subscribe_dialog_login_button_login": "로그인", "prefs_notifications_title": "알림", "prefs_notifications_sound_title": "알림 효과음", diff --git a/web/public/static/langs/nb_NO.json b/web/public/static/langs/nb_NO.json index 0dd9571..4c2932b 100644 --- a/web/public/static/langs/nb_NO.json +++ b/web/public/static/langs/nb_NO.json @@ -113,7 +113,7 @@ "prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke", "prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned", "priority_min": "min.", - "common_back": "Tilbake", + "subscribe_dialog_login_button_back": "Tilbake", "prefs_notifications_delete_after_three_hours": "Etter tre timer", "prefs_users_table_base_url_header": "Tjeneste-nettadresse", "common_cancel": "Avbryt", @@ -187,8 +187,5 @@ "subscribe_dialog_subscribe_base_url_label": "Tjeneste-URL", "prefs_users_table": "Brukertabell", "prefs_users_edit_button": "Rediger bruker", - "error_boundary_unsupported_indexeddb_title": "Privat surfing støttes ikke", - "action_bar_account": "Konto", - "action_bar_profile_settings": "Innstillinger", - "nav_button_account": "Konto" + "error_boundary_unsupported_indexeddb_title": "Privat surfing støttes ikke" } diff --git a/web/public/static/langs/nl.json b/web/public/static/langs/nl.json index 8ccb629..ba54c82 100644 --- a/web/public/static/langs/nl.json +++ b/web/public/static/langs/nl.json @@ -1,6 +1,6 @@ { "action_bar_settings": "Instellingen", - "action_bar_send_test_notification": "Verstuur testnotificatie", + "action_bar_send_test_notification": "Verstuur testnotificatie.", "action_bar_clear_notifications": "Wis alle notificaties", "message_bar_type_message": "Typ hier een bericht", "action_bar_unsubscribe": "Afmelden", @@ -44,7 +44,7 @@ "notifications_mark_read": "Markeer als gelezen", "notifications_delete": "Verwijder", "notifications_copied_to_clipboard": "Gekopieerd naar klembord", - "notifications_tags": "Labels", + "notifications_tags": "Tags", "notifications_priority_x": "Prioriteit {{priority}}", "notifications_new_indicator": "Nieuwe notificatie", "notifications_attachment_image": "Afbeelding bijlage", @@ -140,7 +140,7 @@ "subscribe_dialog_subscribe_title": "Onderwerp abonneren", "subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.", "subscribe_dialog_login_password_label": "Wachtwoord", - "common_back": "Terug", + "subscribe_dialog_login_button_back": "Terug", "subscribe_dialog_login_button_login": "Aanmelden", "subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang", "subscribe_dialog_error_user_anonymous": "anoniem", @@ -187,198 +187,5 @@ "priority_default": "standaard", "priority_high": "hoog", "priority_max": "max", - "error_boundary_unsupported_indexeddb_title": "Privé / incognito browservensters worden niet ondersteund", - "signup_form_username": "Gebruikersnaam", - "signup_form_toggle_password_visibility": "Wachtwoord zichtbaar maken", - "signup_already_have_account": "Heb je al een account? Log in!", - "signup_form_button_submit": "Registreer", - "signup_disabled": "Registreren is uitgeschakeld", - "signup_error_username_taken": "Gebruikersnaam {{username}} is al bezet", - "signup_error_creation_limit_reached": "Limiet voor aanmaken account bereikt", - "login_title": "Aanmelden bij uw ntfy account", - "login_form_button_submit": "Inloggen", - "login_link_signup": "Registreer", - "login_disabled": "Inloggen is uitgeschakeld", - "action_bar_account": "Account", - "action_bar_reservation_add": "Onderwerp reserveren", - "action_bar_reservation_edit": "Reservatie wijzigen", - "action_bar_reservation_delete": "Verwijder reservatie", - "action_bar_reservation_limit_reached": "Limiet bereikt", - "action_bar_profile_title": "Profiel", - "nav_upgrade_banner_label": "Upgrade naar ntfy Pro", - "nav_upgrade_banner_description": "Onderwerpen reserveren, meer berichten & e-mails, en grotere bijlagen", - "alert_not_supported_context_description": "Notificaties worden alleen ondersteund via HTTPS. Dit is een beperking van de Notificaties API.", - "display_name_dialog_placeholder": "Weergavenaam", - "reserve_dialog_checkbox_label": "Onderwerp reserveren en toegang configureren", - "account_basics_title": "Account", - "account_basics_username_title": "Gebruikersnaam", - "account_basics_username_description": "Hé, dat ben jij ❤", - "account_basics_username_admin_tooltip": "Je bent beheerder", - "account_basics_password_title": "Wachtwoord", - "account_basics_password_description": "Wijzig het wachtwoord van je account", - "account_basics_password_dialog_current_password_label": "Huidig wachtwoord", - "account_basics_password_dialog_new_password_label": "Nieuw wachtwoord", - "account_basics_password_dialog_confirm_password_label": "Bevestig wachtwoord", - "account_basics_password_dialog_button_submit": "Wijzig wachtwoord", - "account_basics_password_dialog_current_password_incorrect": "Wachtwoord onjuist", - "account_usage_title": "Gebruik", - "account_usage_of_limit": "van {{limit}}", - "account_usage_unlimited": "Onbeperkt", - "account_basics_tier_title": "Account type", - "account_basics_tier_admin": "Beheerder", - "account_basics_tier_admin_suffix_with_tier": "(met {{tier}} niveau)", - "account_basics_tier_basic": "Basis", - "account_basics_tier_free": "Gratis", - "account_basics_tier_change_button": "Wijzig", - "account_basics_tier_paid_until": "Abonnement betaald tot {{date}}, en wordt automatisch verlengd", - "account_basics_tier_payment_overdue": "Je betaling is te laat. Update je betalingsmethode, anders wordt je account binnenkort gedowngraded.", - "account_basics_tier_canceled_subscription": "Je abonnement is opgezegd en wordt op {{date}} gedowngraded naar een gratis account.", - "signup_form_password": "Wachtwoord", - "signup_title": "Een ntfy account aanmaken", - "signup_form_confirm_password": "Bevestig wachtwoord", - "action_bar_change_display_name": "Weergavenaam wijzigen", - "action_bar_profile_logout": "Uitloggen", - "action_bar_profile_settings": "Instellingen", - "action_bar_sign_up": "Registreer", - "nav_button_account": "Account", - "action_bar_sign_in": "Inloggen", - "display_name_dialog_title": "Weergavenaam wijzigen", - "display_name_dialog_description": "Stel een alternatieve naam in voor een onderwerp dat wordt weergeven in de abonnementenlijst. Dit helpt onderwerpen met gecompliceerde namen gemakkelijker te identificeren.", - "subscribe_dialog_subscribe_button_generate_topic_name": "Naam genereren", - "subscribe_dialog_error_topic_already_reserved": "Onderwerp al gereserveerd", - "account_basics_password_dialog_title": "Wijzig wachtwoord", - "account_usage_limits_reset_daily": "Gebruikslimieten worden dagelijks om middernacht (UTC) gereset", - "account_basics_tier_upgrade_button": "Upgrade naar Pro", - "account_upgrade_dialog_title": "Accountniveau wijzigen", - "account_upgrade_dialog_interval_yearly_discount_save": "bespaar {{discount}}%", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} jaarlijks gefactureerd. Bespaar {{save}}.", - "account_upgrade_dialog_cancel_warning": "Hiermee wordt uw abonnement opgezegd en wordt uw account gedowngraded op {{date}}. Op die datum worden onderwerpreserveringen en berichten in de cache op de server verwijderd .", - "account_tokens_dialog_button_update": "Token bijwerken", - "account_upgrade_dialog_proration_info": "Pro rata: Bij een upgrade tussen betaalde abonnementen wordt het prijsverschil onmiddellijk in rekening gebracht. Wanneer u downgradet naar een lager niveau, wordt het saldo gebruikt om toekomstige factureringsperioden te betalen.", - "account_upgrade_dialog_reservations_warning_one": "Het geselecteerde niveau staat minder gereserveerde onderwerpen toe dan uw huidige niveau. Voordat u uw niveau wijzigt, , moet u ten minste één reservering verwijderen . U kunt reserveringen verwijderen in de Instellingen.", - "account_upgrade_dialog_reservations_warning_other": "Het geselecteerde niveau staat minder gereserveerde onderwerpen toe dan uw huidige niveau. Voordat u uw niveau wijzigt, moet u ten minste {{count}} reserveringen verwijderen. U kunt reserveringen verwijderen in de Instellingen.", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} gereserveerde onderwerpen", - "account_upgrade_dialog_billing_contact_email": "Neem voor vragen over facturering rechtstreeks contact met ons op.", - "account_tokens_table_token_header": "Token", - "account_tokens_table_never_expires": "Verloopt nooit", - "account_tokens_table_current_session": "Huidige browsersessie", - "prefs_reservations_table_everyone_read_only": "Ik kan publiceren en abonneren, iedereen kan zich abonneren", - "prefs_reservations_table_everyone_write_only": "Ik kan publiceren en abonneren, iedereen kan publiceren", - "account_usage_reservations_none": "Geen gereserveerde onderwerpen voor dit account", - "account_usage_attachment_storage_title": "Bijlage-opslag", - "account_usage_attachment_storage_description": "{{filesize}} per bestand, verwijderd na {{expiry}}", - "account_delete_dialog_description": "Hiermee wordt uw account definitief verwijderd, inclusief alle gegevens die op de server zijn opgeslagen. Na verwijdering is uw gebruikersnaam 7 dagen niet beschikbaar. Als u echt wilt doorgaan, bevestig dan met uw wachtwoord in het onderstaande vak.", - "account_delete_dialog_billing_warning": "Als u uw account verwijdert, wordt ook uw facturering onmiddellijk geannuleerd. U heeft dan geen toegang meer tot het factureringsdashboard.", - "account_tokens_dialog_button_cancel": "Annuleren", - "reservation_delete_dialog_submit_button": "Reservering verwijderen", - "prefs_reservations_table_everyone_deny_all": "Alleen ik kan publiceren en abonneren", - "reservation_delete_dialog_description": "Het verwijderen van een reservering geeft het eigendom van het onderwerp op en stelt anderen in staat het te reserveren. U kunt bestaande berichten en bijlagen behouden of verwijderen.", - "account_basics_tier_interval_monthly": "maandelijks", - "account_basics_tier_interval_yearly": "jaarlijks", - "account_usage_basis_ip_description": "Gebruiksstatistieken en -limieten voor dit account zijn gebaseerd op uw IP-adres en kunnen dus worden gedeeld met andere gebruikers. De hierboven weergegeven limieten zijn bij benadering gebaseerd op de bestaande limieten.", - "account_usage_cannot_create_portal_session": "Kan factureringsportaal niet openen", - "account_delete_title": "Account verwijderen", - "account_delete_description": "Verwijder uw account definitief", - "account_delete_dialog_label": "Wachtwoord", - "account_delete_dialog_button_cancel": "Annuleren", - "account_delete_dialog_button_submit": "Verwijder uw account definitief", - "account_upgrade_dialog_interval_monthly": "Maandelijks", - "account_upgrade_dialog_interval_yearly": "Jaarlijks", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "bespaar tot {{discount}}%", - "account_upgrade_dialog_tier_features_no_reservations": "Geen gereserveerde onderwerpen", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} totale opslag", - "account_upgrade_dialog_tier_current_label": "Huidig", - "account_upgrade_dialog_button_update_subscription": "Abonnement bijwerken", - "account_tokens_title": "Toegangstokens", - "account_tokens_description": "Gebruik toegangstokens bij het publiceren en abonneren via de ntfy API, zodat u uw accountgegevens niet hoeft op te sturen. Bekijk de documentatie voor meer informatie.", - "account_tokens_table_label_header": "Label", - "account_tokens_table_cannot_delete_or_edit": "Kan huidige sessietoken niet bewerken of verwijderen", - "account_tokens_dialog_expires_label": "Toegangstoken verloopt over", - "account_tokens_dialog_expires_unchanged": "Vervaldatum ongewijzigd laten", - "account_tokens_dialog_expires_x_hours": "Token verloopt over {{hours}} uur", - "account_tokens_dialog_expires_x_days": "Token verloopt over {{days}} dagen", - "account_tokens_dialog_expires_never": "Token verloopt nooit", - "account_tokens_delete_dialog_title": "Toegangstoken verwijderen", - "account_tokens_delete_dialog_description": "Voordat u een toegangstoken verwijdert, moet u ervoor zorgen dat er geen toepassingen of scripts actief gebruik van maken. Deze actie kan niet ongedaan worden gemaakt.", - "prefs_users_table_cannot_delete_or_edit": "Kan ingelogde gebruiker niet verwijderen of bewerken", - "prefs_reservations_title": "Gereserveerde onderwerpen", - "prefs_reservations_description": "U kunt hier onderwerpnamen reserveren voor persoonlijk gebruik. Door een onderwerp te reserveren, wordt u eigenaar van het onderwerp en kunt u toegangsmachtigingen voor andere gebruikers voor het onderwerp definiëren.", - "prefs_reservations_limit_reached": "Je hebt je limiet voor gereserveerde onderwerpen bereikt.", - "prefs_reservations_add_button": "Gereserveerd onderwerp toevoegen", - "prefs_reservations_table_click_to_subscribe": "Klik om je te abonneren", - "prefs_reservations_dialog_title_add": "Onderwerp reserveren", - "prefs_reservations_dialog_title_edit": "Gereserveerd onderwerp bewerken", - "prefs_reservations_dialog_title_delete": "Onderwerpreservering verwijderen", - "prefs_reservations_dialog_description": "Door een onderwerp te reserveren, wordt u eigenaar van het onderwerp en kunt u toegangsmachtigingen voor andere gebruikers voor het onderwerp definiëren.", - "prefs_reservations_dialog_topic_label": "Onderwerp", - "prefs_reservations_dialog_access_label": "Toegang", - "reservation_delete_dialog_action_keep_title": "Bewaar in de cache opgeslagen berichten en bijlagen", - "reservation_delete_dialog_action_keep_description": "Berichten en bijlagen die in de cache op de server zijn opgeslagen, worden publiekelijk zichtbaar voor mensen die de onderwerpnaam kennen.", - "reservation_delete_dialog_action_delete_description": "Berichten en bijlagen in de cache worden permanent verwijderd. Deze actie kan niet ongedaan gemaakt worden.", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} gereserveerd onderwerp", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} dagelijks bericht", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagelijkse berichten", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagelijkse e-mail", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} dagelijkse e-mails", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per bestand", - "account_upgrade_dialog_tier_price_per_month": "maand", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per jaar. Maandelijks gefactureerd.", - "account_upgrade_dialog_tier_selected_label": "Geselecteerd", - "account_upgrade_dialog_billing_contact_website": "Raadpleeg voor vragen over facturering onze website.", - "account_upgrade_dialog_button_cancel": "Annuleren", - "account_upgrade_dialog_button_redirect_signup": "Nu aanmelden", - "account_upgrade_dialog_button_pay_now": "Nu betalen en inschrijven", - "account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen", - "account_tokens_table_last_access_header": "Laatste toegang", - "account_tokens_table_expires_header": "Verloopt op", - "common_copy_to_clipboard": "Kopieer naar klembord", - "account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd", - "account_tokens_delete_dialog_submit_button": "Token definitief verwijderen", - "prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.", - "reservation_delete_dialog_action_delete_title": "Verwijder in de cache opgeslagen berichten en bijlagen", - "account_basics_tier_description": "Het niveau van uw account", - "account_basics_tier_admin_suffix_no_tier": "(geen niveau)", - "account_basics_tier_manage_billing_button": "Facturering beheren", - "account_usage_messages_title": "Gepubliceerde berichten", - "account_usage_emails_title": "E-mails verzonden", - "account_usage_reservations_title": "Gereserveerde onderwerpen", - "account_tokens_table_create_token_button": "Toegangstoken maken", - "account_tokens_table_last_origin_tooltip": "Vanaf IP-adres {{ip}}, klik om op te zoeken", - "account_tokens_dialog_title_create": "Toegangstoken maken", - "account_tokens_dialog_title_edit": "Toegangstoken bewerken", - "account_tokens_dialog_title_delete": "Toegangstoken verwijderen", - "account_tokens_dialog_label": "Label, bijv. Radarr-meldingen", - "account_tokens_dialog_button_create": "Token maken", - "prefs_reservations_edit_button": "Onderwerptoegang bewerken", - "prefs_reservations_delete_button": "Toegang tot onderwerp resetten", - "prefs_reservations_table": "Tabel met gereserveerde onderwerpen", - "prefs_reservations_table_topic_header": "Onderwerp", - "prefs_reservations_table_access_header": "Toegang", - "prefs_reservations_table_everyone_read_write": "Iedereen kan publiceren en abonneren", - "prefs_reservations_table_not_subscribed": "Niet geabonneerd", - "publish_dialog_call_label": "Telefoongesprek", - "publish_dialog_call_reset": "Telefoongesprek verwijderen", - "publish_dialog_chip_call_label": "Telefoongesprek", - "account_basics_phone_numbers_title": "Telefoonnummers", - "account_basics_phone_numbers_description": "Voor meldingen via telefoongesprekken", - "account_basics_phone_numbers_no_phone_numbers_yet": "Nog geen telefoonnummers", - "account_basics_phone_numbers_dialog_verify_button_call": "Bel me", - "account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagelijkse telefoontjes", - "account_basics_phone_numbers_copied_to_clipboard": "Telefoonnummer gekopieerd naar klembord", - "publish_dialog_call_item": "Bel telefoonnummer {{nummer}}", - "account_basics_phone_numbers_dialog_check_verification_button": "Bevestig code", - "publish_dialog_chip_call_no_verified_numbers_tooltip": "Geen geverifieerde telefoonnummers", - "account_basics_phone_numbers_dialog_channel_call": "Telefoongesprek", - "account_basics_phone_numbers_dialog_number_label": "Telefoonnummer", - "account_basics_phone_numbers_dialog_channel_sms": "SMS", - "account_basics_phone_numbers_dialog_code_placeholder": "bijv. 123456", - "account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagelijkse telefoontjes", - "account_upgrade_dialog_tier_features_no_calls": "Geen telefoontjes", - "account_basics_phone_numbers_dialog_description": "Als u de functie voor oproepmeldingen wilt gebruiken, moet u ten minste één telefoonnummer toevoegen en verifiëren. Verificatie kan worden gedaan via sms of een telefoontje.", - "account_basics_phone_numbers_dialog_title": "Telefoonnummer toevoegen", - "account_basics_phone_numbers_dialog_number_placeholder": "bijv. +1222333444", - "account_basics_phone_numbers_dialog_verify_button_sms": "Stuur SMS", - "account_basics_phone_numbers_dialog_code_label": "Verificatiecode", - "account_usage_calls_title": "Aantal telefoontjes", - "account_usage_calls_none": "Met dit account kan niet worden gebeld" + "error_boundary_unsupported_indexeddb_title": "Privé / incognito browservensters worden niet ondersteund" } diff --git a/web/public/static/langs/pl.json b/web/public/static/langs/pl.json index 9dea2b8..34789e1 100644 --- a/web/public/static/langs/pl.json +++ b/web/public/static/langs/pl.json @@ -107,7 +107,7 @@ "subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil", "subscribe_dialog_login_password_label": "Hasło", "publish_dialog_button_cancel": "Anuluj", - "common_back": "Powrót", + "subscribe_dialog_login_button_back": "Powrót", "subscribe_dialog_login_button_login": "Zaloguj się", "subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień", "subscribe_dialog_error_user_anonymous": "anonim", @@ -187,135 +187,5 @@ "prefs_notifications_delete_after_never": "Nigdy", "prefs_users_dialog_title_edit": "Edytuj użytkownika", "priority_min": "minimum", - "error_boundary_unsupported_indexeddb_description": "Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.

To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać w tym wydaniu GitHub, lub na czacie w Discord lub Matrix.", - "signup_form_password": "Hasło", - "signup_title": "Załóż konto ntfy", - "signup_error_creation_limit_reached": "Przekroczono limit zakładania kont", - "action_bar_reservation_limit_reached": "Limit wyczerpany", - "display_name_dialog_title": "Zmień wyświetlaną nazwę", - "display_name_dialog_description": "Ustaw alternatywną nazwę dla tematu wyświetlanego na liście subskrybcji. To ułatwia identyfikację tematów o skomplikowanych nazwach.", - "account_basics_title": "Konto", - "account_basics_password_dialog_title": "Zmień hasło", - "signup_form_username": "Nawa użytkownika", - "signup_form_confirm_password": "Powtórz hasło", - "signup_form_button_submit": "Załóż konto", - "signup_form_toggle_password_visibility": "Pokaż lub ukryj hasło", - "signup_already_have_account": "Masz już konto? Zaloguj się!", - "signup_disabled": "Zakładanie kont jest wyłączone", - "signup_error_username_taken": "Nazwa użytkownika {{username}} jest już zajęta", - "login_title": "Zaloguj się do swojego konta ntfy", - "login_form_button_submit": "Zaloguj się", - "login_link_signup": "Załóż konto", - "login_disabled": "Logowanie jet wyłączone", - "action_bar_account": "Konto", - "action_bar_change_display_name": "Zmień wyświetlaną nazwę", - "action_bar_reservation_add": "Zarezerwuj temat", - "action_bar_reservation_edit": "Zmień rezerwację", - "action_bar_reservation_delete": "Usuń rezerwację", - "action_bar_profile_title": "Profil", - "action_bar_profile_settings": "Ustawienia", - "action_bar_profile_logout": "Wyloguj", - "action_bar_sign_in": "Zaloguj", - "action_bar_sign_up": "Załóż konto", - "nav_button_account": "Konto", - "display_name_dialog_placeholder": "Nazwa wyświetlana", - "reserve_dialog_checkbox_label": "Zarezerwuj temat i skonfiguruj dostęp", - "subscribe_dialog_subscribe_button_generate_topic_name": "Wygeneruj nazwę", - "subscribe_dialog_error_topic_already_reserved": "Temat już jest zarezerwowany", - "account_basics_username_title": "Nazwa użytkownika", - "account_basics_username_description": "Hej, to Ty ❤", - "account_basics_username_admin_tooltip": "Jesteś Administratorem", - "account_basics_password_title": "Hasło", - "account_basics_password_description": "Zmień hasło do konta", - "account_basics_password_dialog_current_password_label": "Aktualne hasło", - "account_basics_password_dialog_new_password_label": "Nowe hasło", - "account_basics_password_dialog_confirm_password_label": "Powtórz hasło", - "account_basics_password_dialog_button_submit": "Zmień hasło", - "account_basics_password_dialog_current_password_incorrect": "Błędne hasło", - "account_usage_title": "Użycie", - "account_usage_of_limit": "z {{limit}}", - "account_usage_unlimited": "Bez limitu", - "account_usage_limits_reset_daily": "Limity są resetowane codziennie o północy (UTC)", - "account_delete_dialog_button_submit": "Nieodwracalnie usuń konto", - "account_upgrade_dialog_tier_features_no_reservations": "Brak rezerwacji tematów", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na plik", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} pamięci łącznie", - "account_upgrade_dialog_tier_price_per_month": "miesiąc", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} na rok. Płatne miesięcznie.", - "account_upgrade_dialog_billing_contact_email": "W razie pytań dotyczących rozliczeń skontaktuj się z nami bezpośrednio.", - "account_upgrade_dialog_billing_contact_website": "W razie pytań dotyczących rozliczeń sprawdź naszą stronę.", - "account_upgrade_dialog_button_cancel_subscription": "Anuluj subskrypcję", - "account_upgrade_dialog_button_update_subscription": "Zmień subskrypcję", - "account_tokens_title": "Tokeny dostępowe", - "account_tokens_table_token_header": "Token", - "account_tokens_table_label_header": "Etykieta", - "account_tokens_table_last_access_header": "Ostatnie użycie", - "account_tokens_table_expires_header": "Termin ważności", - "account_tokens_table_never_expires": "Bezterminowy", - "account_tokens_table_current_session": "Aktualna sesja przeglądarki", - "common_copy_to_clipboard": "Kopiuj do schowka", - "account_tokens_table_copied_to_clipboard": "Token został skopiowany", - "account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji", - "account_tokens_table_create_token_button": "Utwórz token dostępowy", - "account_tokens_dialog_label": "Etykieta, np. Powiadomienia Radarr", - "account_tokens_dialog_button_update": "Zmień token", - "account_basics_tier_interval_monthly": "miesięcznie", - "account_basics_tier_interval_yearly": "rocznie", - "account_upgrade_dialog_interval_monthly": "Miesięcznie", - "account_upgrade_dialog_title": "Zmień plan konta", - "account_delete_dialog_description": "Konto, wraz ze wszystkimi związanymi z nim danymi przechowywanymi na serwerze, będzie nieodwracalnie usunięte. Po usunięciu Twoja nazwa użytkownika będzie niedostępna jeszcze przez 7 dni. Jeśli chcesz kontynuować, potwierdź wpisując swoje hasło w polu poniżej.", - "account_delete_dialog_billing_warning": "Usunięcie konta powoduje natychmiastowe anulowanie subskrypcji. Nie będziesz już mieć dostępu do strony z rachunkami.", - "account_upgrade_dialog_interval_yearly": "Rocznie", - "account_upgrade_dialog_interval_yearly_discount_save": "taniej o {{discount}}%", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "nawet {{discount}}% taniej", - "account_upgrade_dialog_button_cancel": "Anuluj", - "account_tokens_description": "Używaj tokenów do publikowania wiadomości i subskrybowania tematów przez API ntfy, żeby uniknąć konieczności podawania danych do logowania. Szczegóły znajdziesz w dokumentacji.", - "account_tokens_dialog_title_create": "Utwórz token dostępowy", - "account_tokens_table_last_origin_tooltip": "Z adresu IP {{ip}}, kliknij żeby sprawdzić", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} płatne jednorazowo. Oszczędzasz {{save}}.", - "account_tokens_dialog_title_edit": "Edytuj token dostępowy", - "account_tokens_dialog_title_delete": "Usuń token dostępowy", - "account_tokens_dialog_button_create": "Utwórz token", - "nav_upgrade_banner_label": "Przejdź na ntfy Pro", - "nav_upgrade_banner_description": "Rezerwuj tematy, więcej powiadomień i maili oraz większe załączniki", - "alert_not_supported_context_description": "Powiadomienia działają tylko przez HTTPS. To jest ograniczenie Notifications API.", - "account_basics_tier_canceled_subscription": "Twoja subskrypcja została anulowana i konto zostanie ograniczone do wersji darmowej w dniu {{date}}.", - "account_basics_tier_manage_billing_button": "Zarządzaj rachunkami", - "account_usage_messages_title": "Wysłane wiadomości", - "account_usage_emails_title": "Wysłane maile", - "account_basics_tier_title": "Rodzaj konta", - "account_basics_tier_description": "Mocarność Twojego konta", - "account_basics_tier_admin": "Administrator", - "account_basics_tier_admin_suffix_with_tier": "(plan {{tier}})", - "account_basics_tier_admin_suffix_no_tier": "(brak planu)", - "account_basics_tier_basic": "Podstawowe", - "account_basics_tier_free": "Darmowe", - "account_basics_tier_upgrade_button": "Przejdź na Pro", - "account_basics_tier_change_button": "Zmień", - "account_basics_tier_paid_until": "Subskrypcja opłacona do {{date}} i będzie odnowiona automatycznie", - "account_basics_tier_payment_overdue": "Minął termin płatności. Zaktualizuj metodę płatności, w przeciwnym razie Twoje konto wkrótce zostanie ograniczone.", - "account_usage_reservations_title": "Zarezerwowane tematy", - "account_usage_reservations_none": "Brak zarezerwowanych tematów na tym koncie", - "account_usage_attachment_storage_title": "Miejsce na załączniki", - "account_usage_attachment_storage_description": "{{filesize}} na każdy plik, przechowywane przez {{expiry}}", - "account_usage_basis_ip_description": "Statystyki i limity dla tego konta bazują na Twoim adresie IP, więc mogą być współdzielone z innymi użytkownikami. Limity pokazane powyżej to wartości przybliżone bazujące na rzeczywistych limitach.", - "account_usage_cannot_create_portal_session": "Nie można otworzyć portalu z rachunkami", - "account_delete_title": "Usuń konto", - "account_delete_description": "Usuń swoje konto nieodwracalnie", - "account_delete_dialog_label": "Hasło", - "account_delete_dialog_button_cancel": "Anuluj", - "account_upgrade_dialog_button_redirect_signup": "Załóż konto", - "account_upgrade_dialog_button_pay_now": "Zapłać i aktywuj subskrypcję", - "account_tokens_dialog_button_cancel": "Anuluj", - "account_tokens_dialog_expires_label": "Token dostępowy wygasa po", - "account_tokens_dialog_expires_unchanged": "Pozostaw termin ważności bez zmian", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezerwacja tematu", - "account_upgrade_dialog_tier_features_reservations_few": "{{reservations}} rezerwacje tematów", - "account_upgrade_dialog_tier_features_reservations_many": "{{reservations}} rezerwacji tematów", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} mail dziennie", - "account_upgrade_dialog_tier_features_emails_few": "{{emails}} maile dziennie", - "account_upgrade_dialog_tier_features_emails_many": "{{emails}} maili dziennie", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} wiadomość dziennie", - "account_upgrade_dialog_tier_features_messages_few": "{{messages}} wiadomości dziennie", - "account_upgrade_dialog_tier_features_messages_many": "{{messages}} wiadomości dziennie" + "error_boundary_unsupported_indexeddb_description": "Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.

To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać w tym wydaniu GitHub, lub na czacie w Discord lub Matrix." } diff --git a/web/public/static/langs/pt.json b/web/public/static/langs/pt.json index 57d5656..7c49e20 100644 --- a/web/public/static/langs/pt.json +++ b/web/public/static/langs/pt.json @@ -31,7 +31,7 @@ "notifications_attachment_copy_url_title": "Copiar URL do anexo para a área de transferência", "notifications_attachment_copy_url_button": "Copiar URL", "notifications_attachment_open_title": "Ir para {{url}}", - "notifications_attachment_link_expired": "a ligação de descarga expirou", + "notifications_attachment_link_expired": "a ligação de transferência expirou", "notifications_attachment_open_button": "Abrir anexo", "notifications_attachment_link_expires": "a ligação expira em {{date}}", "notifications_attachment_file_image": "ficheiro de imagem", @@ -144,7 +144,7 @@ "subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.", "subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"", "subscribe_dialog_login_password_label": "Palavra-passe", - "common_back": "Voltar", + "subscribe_dialog_login_button_back": "Voltar", "subscribe_dialog_login_button_login": "Autenticar", "subscribe_dialog_error_user_anonymous": "anónimo", "prefs_notifications_title": "Notificações", @@ -187,44 +187,5 @@ "priority_high": "alta", "priority_max": "máxima", "error_boundary_title": "Oh não, o ntfy parou de funcionar", - "error_boundary_button_copy_stack_trace": "Copiar erro (\"stack trace\")", - "signup_title": "Criar uma conta ntfy", - "signup_form_username": "Nome de utilizador", - "signup_form_confirm_password": "Confirmar palavra-passe", - "signup_form_button_submit": "Registar", - "signup_form_toggle_password_visibility": "Alternar visibilidade da palavra-passe", - "signup_already_have_account": "Já tem uma conta? Inicie sessão!", - "signup_disabled": "Novos registos desativados", - "signup_error_username_taken": "O nome \"{{username}}\" já está em uso", - "signup_error_creation_limit_reached": "Limite de criação de contas atingido", - "login_title": "Inicie sessão na sua conta ntfy", - "login_form_button_submit": "Iniciar sessão", - "login_disabled": "Início de sessão desativado", - "action_bar_account": "Conta", - "action_bar_change_display_name": "Alterar nome de exibição", - "action_bar_reservation_delete": "Remover reserva", - "action_bar_reservation_limit_reached": "Limite alcançado", - "action_bar_profile_title": "Perfil", - "action_bar_profile_settings": "Configurações", - "action_bar_profile_logout": "Terminar sessão", - "action_bar_sign_in": "Iniciar sessão", - "nav_upgrade_banner_description": "Reserve tópicos, envie mais mensagens, emails e anexos maiores", - "signup_form_password": "Palavra-passe", - "action_bar_reservation_edit": "Alterar reserva", - "login_link_signup": "Registar", - "action_bar_reservation_add": "Reservar tópico", - "action_bar_sign_up": "Registar", - "nav_button_account": "Conta", - "common_copy_to_clipboard": "Copiar", - "nav_upgrade_banner_label": "Atualizar para ntfy Pro", - "alert_not_supported_context_description": "Notificações são suportadas apenas sobre HTTPS. Essa é uma limitação da API de Notificações.", - "display_name_dialog_title": "Alterar nome mostrado", - "display_name_dialog_description": "Configura um nome alternativo ao tópico que é mostrado na lista de assinaturas. Isto ajuda a identificar tópicos com nomes complicados mais facilmente.", - "display_name_dialog_placeholder": "Nome exibido", - "reserve_dialog_checkbox_label": "Reservar tópico e configurar acesso", - "publish_dialog_call_label": "Chamada telefônica", - "publish_dialog_call_placeholder": "Número de telefone para ligar com a mensagem, ex: +12223334444, ou 'Sim'", - "publish_dialog_call_reset": "Remover chamada telefônica", - "publish_dialog_chip_call_label": "Chamada telefônica", - "subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome" + "error_boundary_button_copy_stack_trace": "Copiar erro (\"stack trace\")" } diff --git a/web/public/static/langs/pt_BR.json b/web/public/static/langs/pt_BR.json index acf5bca..79622be 100644 --- a/web/public/static/langs/pt_BR.json +++ b/web/public/static/langs/pt_BR.json @@ -93,7 +93,7 @@ "prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima", "prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima", "subscribe_dialog_login_password_label": "Senha", - "common_back": "Voltar", + "subscribe_dialog_login_button_back": "Voltar", "prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima", "prefs_notifications_min_priority_max_only": "Apenas prioridade máxima", "prefs_notifications_delete_after_title": "Apagar notificações", diff --git a/web/public/static/langs/ro.json b/web/public/static/langs/ro.json index d9cb66e..394b9dd 100644 --- a/web/public/static/langs/ro.json +++ b/web/public/static/langs/ro.json @@ -7,7 +7,5 @@ "action_bar_logo_alt": "logo-ul ntfy", "action_bar_toggle_mute": "Oprire/activare notificări", "message_bar_type_message": "Scrie un mesaj aici", - "message_bar_error_publishing": "Eroare la publicarea notificării", - "action_bar_profile_title": "Profil", - "action_bar_profile_settings": "Setări" + "message_bar_error_publishing": "Eroare la publicarea notificării" } diff --git a/web/public/static/langs/ru.json b/web/public/static/langs/ru.json index 9633d97..c629e52 100644 --- a/web/public/static/langs/ru.json +++ b/web/public/static/langs/ru.json @@ -1,30 +1,30 @@ { - "publish_dialog_priority_min": "Минимальный приоритет", + "publish_dialog_priority_min": "Мин. приоритет", "action_bar_settings": "Настройки", "action_bar_send_test_notification": "Отправить тестовое уведомление", "action_bar_clear_notifications": "Удалить все уведомления", "action_bar_unsubscribe": "Отписаться", "message_bar_type_message": "Введите сообщение здесь", - "notifications_none_for_topic_description": "Чтобы отправить уведомление на данную тему, просто сделаете PUT или POST-запрос на URL-адрес этой темы.", - "notifications_none_for_any_description": "Чтобы отправить уведомление на тему, просто сделаете PUT или POST-запрос на её URL-адрес. Вот пример с использованием одной из ваших тем.", - "notifications_no_subscriptions_title": "Похоже, что у вас ещё нет подписок.", + "notifications_none_for_topic_description": "Чтобы отправить уведомление на данную тему, просто отправьте PUT или POST на URL-адрес этой темы.", + "notifications_none_for_any_description": "Чтобы отправить уведомления на тему, просто отправьте PUT или POST на URL-адрес темы. Вот пример используя одну из ваших тем.", + "notifications_no_subscriptions_title": "Похоже у вас ещё нет подписок.", "alert_grant_description": "Разрешите браузеру показывать уведомления.", - "notifications_no_subscriptions_description": "Нажмите на ссылку \"{{linktext}}\", чтобы создать или подписаться на тему. После этого Вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.", + "notifications_no_subscriptions_description": "Нажмите \"{{linktext}}\" ссылку, чтобы создать или подписаться на тему. После этого вы сможете отправлять сообщения используя PUT или POST, и вы будете получать здесь уведомления.", "notifications_example": "Пример", - "notifications_more_details": "Для более подробной информации, посетите наш сайт или документацию.", - "notifications_loading": "Идет загрузка уведомлений …", + "notifications_more_details": "Дополнительную информацию найдёте на сайте или в документации.", + "notifications_loading": "Загружаются уведомления …", "publish_dialog_title_topic": "Опубликовать в {{topic}}", "publish_dialog_title_no_topic": "Опубликовать уведомление", - "publish_dialog_progress_uploading": "Идет загрузка …", + "publish_dialog_progress_uploading": "Загружается …", "publish_dialog_progress_uploading_detail": "Загружается {{loaded}}/{{total}} ({{percent}}%) …", "publish_dialog_message_published": "Уведомление опубликовано", - "publish_dialog_attachment_limits_file_and_quota_reached": "превышает максимальный размер файла {{fileSizeLimit}} и квоту, осталось {{remainingBytes}}", - "publish_dialog_attachment_limits_file_reached": "превышает максимальный размер файла {{fileSizeLimit}}", - "publish_dialog_attachment_limits_quota_reached": "превышает квоту, осталось {{remainingBytes}}", + "publish_dialog_attachment_limits_file_and_quota_reached": "превышает {{fileSizeLimit}} размер файла, {{remainingBytes}} осталось", + "publish_dialog_attachment_limits_file_reached": "превышает {{fileSizeLimit}} размер файла", + "publish_dialog_attachment_limits_quota_reached": "превышает квоту, {{remainingBytes}} осталось", "publish_dialog_priority_low": "Низкий приоритет", - "publish_dialog_priority_default": "Стандартный приоритет", + "publish_dialog_priority_default": "Приоритет по умолчанию", "publish_dialog_priority_high": "Высокий приоритет", - "publish_dialog_priority_max": "Максимальный приоритет", + "publish_dialog_priority_max": "Макс. приоритет", "publish_dialog_base_url_label": "URL-адрес сервиса", "publish_dialog_base_url_placeholder": "URL-адрес сервиса, например https://example.com", "publish_dialog_topic_label": "Название темы", @@ -32,14 +32,14 @@ "publish_dialog_title_label": "Заголовок", "publish_dialog_title_placeholder": "Заголовок уведомления, например Disk space alert", "publish_dialog_message_label": "Сообщение", - "publish_dialog_message_placeholder": "Введите сообщение здесь", + "publish_dialog_message_placeholder": "Текст сообщения", "publish_dialog_tags_label": "Тэги", - "publish_dialog_tags_placeholder": "Список тэгов, разделённый запятой, например: warning, srv1-backup", + "publish_dialog_tags_placeholder": "Список тэгов, разделённый запятой, например warning, srv1-backup", "publish_dialog_priority_label": "Приоритет", - "publish_dialog_click_label": "Ссылка при открытии", - "publish_dialog_click_placeholder": "URL-адрес, который откроется при нажатии на уведомление", - "publish_dialog_email_label": "Электронная почта", - "message_bar_error_publishing": "Ошибка публикации уведомления", + "publish_dialog_click_label": "Нажмите на URL-адрес", + "publish_dialog_click_placeholder": "URL-адрес который откроется когда будет нажато уведомление", + "publish_dialog_email_label": "Эл. почта", + "message_bar_error_publishing": "Ошибка отправки уведомления", "alert_not_supported_title": "Уведомления не поддерживаются", "alert_not_supported_description": "Уведомления не поддерживаются вашим браузером.", "notifications_copied_to_clipboard": "Скопировано в буфер обмена", @@ -66,30 +66,30 @@ "notifications_click_open_button": "Открыть ссылку", "subscribe_dialog_subscribe_title": "Подписаться на тему", "publish_dialog_button_cancel": "Отмена", - "subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки Вы сможете отправлять уведомления используя PUT/POST-запросы.", + "subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки вы можете размещать/отправлять уведомления.", "prefs_users_description": "Добавляйте/удаляйте пользователей для защищенных тем. Обратите внимание, что имя пользователя и пароль хранятся в локальном хранилище браузера.", - "error_boundary_description": "Это не должно было случиться. Нам очень жаль.
Если Вы можете уделить минуту своего времени, пожалуйста сообщите об этом на GitHub, или дайте нам знать через Discord или Matrix.", + "error_boundary_description": "Этого, очевидно, не должно происходить. Очень сожалею об этом.
Если у вас есть минутка, пожалуйста сообщить об этом на GitHub, или сообщите нам через Discord или Matrix.", "publish_dialog_email_placeholder": "Адрес для пересылки уведомления. Например, phil@example.com", "publish_dialog_attach_placeholder": "Прикрепите файл по URL. Например, https://f-droid.org/F-Droid.apk", "publish_dialog_filename_label": "Имя файла", "publish_dialog_delay_label": "Задержка", - "publish_dialog_delay_placeholder": "Задержка доставки. Например, {{unixTimestamp}}, {{relativeTime}}, или \"{{naturalLanguage}}\" (только по-английски)", - "publish_dialog_chip_click_label": "URL-адрес при нажатии", + "publish_dialog_delay_placeholder": "Задержка доставки. Например, {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)", + "publish_dialog_chip_click_label": "Адрес", "publish_dialog_chip_email_label": "Переслать на электронную почту", "publish_dialog_chip_attach_url_label": "Прикрепить файл по URL", "publish_dialog_chip_attach_file_label": "Прикрепить локальный файл", - "publish_dialog_chip_delay_label": "Задержать доставку", + "publish_dialog_chip_delay_label": "Задержка отправки", "publish_dialog_chip_topic_label": "Изменить тему", - "publish_dialog_details_examples_description": "Примеры и подробное описание всех функций смотрите в документации.", + "publish_dialog_details_examples_description": "Примеры и подробное описание всех функций см. в e документации.", "publish_dialog_attach_label": "URL-адрес вложения", "publish_dialog_filename_placeholder": "Имя файла вложения", "publish_dialog_other_features": "Другие возможности:", "publish_dialog_button_cancel_sending": "Отменить отправку", "publish_dialog_button_send": "Отправить", "publish_dialog_checkbox_publish_another": "Опубликовать еще", - "publish_dialog_attached_file_title": "Прикреплённый файл:", + "publish_dialog_attached_file_title": "Прикрепленный файл:", "publish_dialog_attached_file_filename_placeholder": "Имя прикреплённого файла", - "emoji_picker_search_placeholder": "Поиск смайликов", + "emoji_picker_search_placeholder": "Поиск эмодзи", "subscribe_dialog_subscribe_topic_placeholder": "Название темы. Например, phil_alerts", "subscribe_dialog_subscribe_use_another_label": "Использовать другой сервер", "subscribe_dialog_subscribe_button_cancel": "Отмена", @@ -98,26 +98,26 @@ "subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.", "subscribe_dialog_login_username_label": "Имя пользователя. Например, phil", "subscribe_dialog_login_password_label": "Пароль", - "common_back": "Назад", + "subscribe_dialog_login_button_back": "Назад", "subscribe_dialog_login_button_login": "Войти", "subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован", - "subscribe_dialog_error_user_anonymous": "анонимный пользователь", + "subscribe_dialog_error_user_anonymous": "аноним", "prefs_notifications_title": "Уведомления", "prefs_notifications_sound_title": "Звук уведомления", "prefs_notifications_sound_description_none": "Уведомления не воспроизводят никаких звуков при получении", "prefs_notifications_sound_no_sound": "Без звука", "prefs_notifications_min_priority_title": "Минимальный приоритет", - "prefs_notifications_min_priority_description_any": "Показывать все уведомления, независимо от приоритета", + "prefs_notifications_min_priority_description_any": "Показать все уведомления, независимо от приоритета", "prefs_notifications_min_priority_description_x_or_higher": "Показывать уведомления, если приоритет {{number}} ({{name}}) или выше", - "prefs_notifications_min_priority_description_max": "Показывать уведомления, если приоритет равен 5 (максимальный)", + "prefs_notifications_min_priority_description_max": "Показывать уведомления, если приоритет равен 5 (максимум)", "prefs_notifications_min_priority_any": "Любой приоритет", - "prefs_notifications_min_priority_low_and_higher": "Низкий приоритет и выше", + "prefs_notifications_min_priority_low_and_higher": "Низкий и высокий приоритет", "prefs_notifications_min_priority_max_only": "Только максимальный приоритет", "prefs_notifications_delete_after_title": "Удалить уведомления", "prefs_notifications_delete_after_never": "Никогда", "prefs_notifications_delete_after_three_hours": "Через три часа", "prefs_notifications_sound_description_some": "Уведомления воспроизводят звук {{sound}}", - "prefs_notifications_min_priority_default_and_higher": "Стандартный приоритет и выше", + "prefs_notifications_min_priority_default_and_higher": "Приоритет по умолчанию и высокий", "prefs_notifications_delete_after_one_day": "Через день", "prefs_notifications_delete_after_one_week": "Через неделю", "prefs_notifications_delete_after_one_month": "Через месяц", @@ -129,10 +129,10 @@ "prefs_users_title": "Управление пользователями", "prefs_users_add_button": "Добавить пользователя", "prefs_users_table_user_header": "Пользователь", - "prefs_users_table_base_url_header": "URL сервера", + "prefs_users_table_base_url_header": "URL службы", "prefs_users_dialog_title_add": "Добавить пользователя", "prefs_users_dialog_title_edit": "Редактировать пользователя", - "prefs_users_dialog_base_url_label": "URL-адрес сервера. Например, https://ntfy.sh", + "prefs_users_dialog_base_url_label": "URL-адрес службы. Например, https://ntfy.sh", "prefs_users_dialog_username_label": "Имя пользователя. Например, phil", "prefs_users_dialog_password_label": "Пароль", "common_cancel": "Отмена", @@ -140,217 +140,19 @@ "common_save": "Сохранить", "prefs_appearance_title": "Внешний вид", "prefs_appearance_language_title": "Язык", - "priority_min": "минимальный", + "priority_min": "минимум", "priority_low": "низкий", - "priority_default": "стандартный", + "priority_default": "по умолчанию", "priority_high": "высокий", "priority_max": "максимальный", - "error_boundary_title": "О нет, ntfy сломался", - "error_boundary_button_copy_stack_trace": "Скопировать трассировку стека", + "error_boundary_title": "О нет, Ntfy сломался", + "error_boundary_button_copy_stack_trace": "Копирование трассировки стека", "error_boundary_stack_trace": "Трассировка стека", - "error_boundary_gathering_info": "Идет сбор дополнительной информации …", - "publish_dialog_drop_file_here": "Перетащите файл сюда", + "error_boundary_gathering_info": "Соберите больше информации …", + "publish_dialog_drop_file_here": "Перетащите файл юда", "prefs_notifications_min_priority_high_and_higher": "Высокий приоритет и выше", "action_bar_toggle_action_menu": "Открыть/закрыть меню", "action_bar_show_menu": "Показать меню", - "action_bar_logo_alt": "Логотип ntfy", - "emoji_picker_search_clear": "Сбросить поиск", - "account_upgrade_dialog_cancel_warning": "Это действие отменит Вашу подписку и переведет Вашую учетную запись на бесплатное обслуживание {{date}}. При наступлении этой даты, все резервирования и сообщения в кэше будут удалены.", - "account_tokens_table_create_token_button": "Создать токен доступа", - "account_tokens_table_last_origin_tooltip": "с IP-адреса {{ip}}, нажмите для подробностей", - "account_tokens_dialog_title_edit": "Изменить токен доступа", - "account_delete_dialog_button_cancel": "Отмена", - "account_delete_dialog_billing_warning": "Удаление учетной записи также отменяет все платные подписки. У Вас не будет доступа к порталу оплаты.", - "account_delete_dialog_description": "Это действие безвозвратно удалит Вашу учетную запись, включая все Ваши данные хранящиеся на сервере. После удаления, Ваше имя пользователя не будет доступно для регистрации в течении 7 дней. Если Вы действительно хотите продолжить, пожалуйста введите Ваш пароль ниже.", - "account_delete_dialog_label": "Пароль", - "reservation_delete_dialog_action_keep_description": "Сообщения и вложения которые находятся в кэше сервера станут доступны всем, кто знает имя темы.", - "prefs_reservations_table": "Список зарезервированных тем", - "prefs_reservations_table_access_header": "Доступ", - "prefs_reservations_table_everyone_write_only": "Я могу публиковать и подписываться, все остальные могут публиковать", - "prefs_reservations_dialog_description": "Резервирование дает Вам возможность управлять темой и настраивать правила доступа к ней для пользователей.", - "reservation_delete_dialog_action_delete_title": "Удалить сообщения в кэше и вложения", - "reservation_delete_dialog_action_delete_description": "Сообщения в кэше и вложения будут безвозвратно удалены. Это действие невозможно отменить.", - "prefs_reservations_table_not_subscribed": "Не подписан", - "prefs_reservations_table_everyone_deny_all": "Только я могу публиковать и подписываться", - "prefs_reservations_table_everyone_read_write": "Все могут публиковать и подписываться", - "prefs_reservations_table_click_to_subscribe": "Нажмите чтобы подписаться", - "prefs_reservations_dialog_title_add": "Зарезервировать тему", - "prefs_reservations_dialog_title_delete": "Удалить резервирование", - "prefs_reservations_dialog_title_edit": "Изменение резервированной темы", - "prefs_reservations_table_topic_header": "Тема", - "prefs_users_description_no_sync": "Пользователи и пароли не синхронизируются с Вашей учетной записью.", - "prefs_users_delete_button": "Удалить пользователя", - "prefs_users_table_cannot_delete_or_edit": "Невозможно удалить или редактировать залогиненного пользователя", - "account_upgrade_dialog_reservations_warning_one": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, пожалуйста удалите хотя бы одну зарезервированную тему. Вы можете это сделать в Настройках.", - "account_upgrade_dialog_proration_info": "Пересчёт оплаты: при расширении подписки, разница в цене от текущей спишется сразу. При упрощении подписки, неиспользованные средства пойдут в оплату баланса по следующим счетам.", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл", - "account_tokens_table_never_expires": "Никогда", - "account_tokens_table_copied_to_clipboard": "Токен доступа скопирован", - "account_tokens_table_cannot_delete_or_edit": "Невозможно изменить или удалить токен текущего сеанса", - "account_tokens_delete_dialog_description": "Перед удалением токена доступа, убедитесь что он не используется приложениями и скриптами. Это действие невозможно отменить.", - "error_boundary_unsupported_indexeddb_title": "Работа в приватном режиме не поддерживается", - "account_tokens_dialog_button_create": "Создать токен", - "account_tokens_delete_dialog_submit_button": "Безвозвратно удалить токен", - "account_upgrade_dialog_reservations_warning_other": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, пожалуйста удалите хотя бы {{count}} зарезервированных тем. Вы можете это сделать в Настройках.", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} сообщений в день", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} суммарный объем", - "account_upgrade_dialog_tier_selected_label": "Выбранная", - "account_tokens_table_current_session": "Текущий сеанс браузера", - "account_tokens_dialog_button_update": "Изменить токен", - "account_tokens_dialog_expires_label": "Токен доступа истекает", - "account_tokens_dialog_expires_x_hours": "Токен истекает через {{hours}} часов", - "account_tokens_dialog_expires_never": "Токен никогда не истекает", - "prefs_notifications_sound_play": "Воспроизводить выбранный звук", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервированных тем", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} эл. сообщений в день", - "account_basics_tier_free": "Бесплатный", - "account_tokens_dialog_title_create": "Создать токен доступа", - "account_tokens_dialog_title_delete": "Удалить токен доступа", - "common_copy_to_clipboard": "Скопировать в буфер обмена", - "account_tokens_dialog_button_cancel": "Отмена", - "account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений", - "account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней", - "account_tokens_delete_dialog_title": "Удалить токен доступа", - "prefs_users_table": "Список пользоваетелй", - "account_upgrade_dialog_tier_current_label": "Текущая", - "account_upgrade_dialog_button_cancel": "Отмена", - "prefs_users_edit_button": "Редактировать пользователя", - "account_basics_tier_upgrade_button": "Подписаться на Pro", - "account_basics_tier_paid_until": "Подписка оплачена до {{date}} и будет продляться автоматически", - "account_basics_tier_change_button": "Изменить", - "account_delete_dialog_button_submit": "Безвозвратно удалить учетную запись", - "account_upgrade_dialog_title": "Изменить уровень учетной записи", - "account_usage_basis_ip_description": "Статистика и ограничения на использование учитываются по IP-адресу, поэтому они могут совмещаться с другими пользователями. Уровни, указанные выше, примерно соответствуют текущим ограничениям.", - "publish_dialog_topic_reset": "Сбросить тему", - "account_basics_tier_admin_suffix_no_tier": "(без подписки)", - "prefs_reservations_dialog_topic_label": "Тема", - "signup_form_username": "Имя пользователя", - "signup_form_password": "Пароль", - "signup_form_confirm_password": "Подтвердите пароль", - "signup_form_button_submit": "Зарегистрироваться", - "signup_form_toggle_password_visibility": "Показать/скрыть пароль", - "signup_disabled": "Регистрация недоступна", - "signup_error_username_taken": "Имя пользователя {{username}} уже занято", - "signup_title": "Создать учетную запись ntfy", - "signup_already_have_account": "Уже есть учетная запись? Войдите!", - "signup_error_creation_limit_reached": "Лимит на создание учетных записей исчерпан", - "login_form_button_submit": "Вход", - "login_link_signup": "Регистрация", - "login_disabled": "Вход недоступен", - "action_bar_reservation_add": "Зарезервировать тему", - "action_bar_reservation_edit": "Изменить резервирование", - "action_bar_reservation_delete": "Удалить резервирование", - "action_bar_profile_title": "Профиль", - "action_bar_profile_settings": "Настройки", - "action_bar_profile_logout": "Выход", - "action_bar_sign_in": "Вход", - "action_bar_sign_up": "Регистрация", - "action_bar_change_display_name": "Изменить псевдоним", - "message_bar_publish": "Опубликовать сообщение", - "nav_button_muted": "Уведомления заглушены", - "nav_button_connecting": "установка соединения", - "action_bar_account": "Учетная запись", - "login_title": "Вход в Вашу учетную запись ntfy", - "action_bar_reservation_limit_reached": "Лимит исчерпан", - "action_bar_toggle_mute": "Заглушить/разрешить уведомления", - "nav_button_account": "Учетная запись", - "nav_upgrade_banner_label": "Подпишитесь на ntfy Pro", - "message_bar_show_dialog": "Открыть диалог публикации", - "notifications_list": "Список уведомлений", - "notifications_list_item": "Уведомление", - "notifications_mark_read": "Пометить как прочтенное", - "notifications_priority_x": "Приоритет {{priority}}", - "notifications_attachment_image": "Приложенное изображение", - "notifications_attachment_file_audio": "звуковой файл", - "notifications_attachment_file_video": "видео файл", - "notifications_attachment_file_image": "графический файл", - "notifications_attachment_file_app": "исполняемый файл Android", - "notifications_attachment_file_document": "другой тип файла", - "notifications_actions_not_supported": "Действие не поддерживается в веб-приложении", - "display_name_dialog_title": "Изменить псевдоним", - "display_name_dialog_description": "Создайте псевдоним для темы, который будет отображаться в списке Ваших подписок. Это помогает легче находить темы со сложными именами.", - "reserve_dialog_checkbox_label": "Зарезервировать тему и настроить доступ", - "publish_dialog_emoji_picker_show": "Выбрать смайлик", - "publish_dialog_click_reset": "Удалить ссылку", - "publish_dialog_email_reset": "Удалить адрес для пересылки", - "publish_dialog_attach_reset": "Удалить URL-адрес вложения", - "publish_dialog_delay_reset": "Удалить задержку доставки", - "publish_dialog_attached_file_remove": "Удалить прикреплённый файл", - "subscribe_dialog_subscribe_base_url_label": "URL-адрес сервера", - "subscribe_dialog_subscribe_button_generate_topic_name": "Сгенерировать случайное имя", - "subscribe_dialog_error_topic_already_reserved": "Тема уже зарезервирована", - "account_basics_title": "Учетная запись", - "account_basics_username_title": "Имя пользователя", - "account_basics_username_admin_tooltip": "Вы Администратор", - "account_basics_password_title": "Пароль", - "account_basics_username_description": "Это Вы! :)", - "account_basics_password_description": "Смена пароля учетной записи", - "account_basics_password_dialog_title": "Смена пароля", - "account_basics_password_dialog_current_password_label": "Текущий пароль", - "account_basics_password_dialog_current_password_incorrect": "Введен неверный пароль", - "account_usage_title": "Использование", - "account_usage_of_limit": "из {{limit}}", - "account_usage_unlimited": "Неограниченно", - "account_usage_limits_reset_daily": "Ограничения сбрасываются ежедневно в полночь (UTC)", - "account_basics_tier_description": "Уровень Вашей учетной записи", - "account_basics_tier_admin": "Администратор", - "account_basics_tier_admin_suffix_with_tier": "(с {{tier}} подпиской)", - "account_basics_tier_payment_overdue": "У Вас задолженность по оплате. Пожалуйста проверьте метод оплаты, иначе Вы скоро потеряете преимущества Вашей подписки.", - "account_basics_tier_canceled_subscription": "Ваша подписка была отменена; учетная запись перейдет на бесплатное обслуживание {{date}}.", - "account_basics_tier_manage_billing_button": "Управление оплатой", - "account_usage_messages_title": "Опубликованные сообщения", - "account_usage_emails_title": "Отправленные электронные сообщения", - "account_usage_reservations_title": "Зарезервированные темы", - "account_usage_reservations_none": "Нет зарезервированных тем", - "account_usage_attachment_storage_title": "Хранение вложений", - "account_usage_attachment_storage_description": "{{filesize}} за файл, удаляются спустя {{expiry}}", - "account_usage_cannot_create_portal_session": "Невозможно открыть портал оплаты", - "account_delete_title": "Удалить учетную запись", - "account_delete_description": "Безвозвратно удалить Вашу учетную запись", - "account_upgrade_dialog_button_redirect_signup": "Зарегистрироваться", - "account_upgrade_dialog_button_pay_now": "Оплатить и подписаться", - "account_upgrade_dialog_button_cancel_subscription": "Отменить подписку", - "account_upgrade_dialog_button_update_subscription": "Изменить подписку", - "account_tokens_title": "Токены доступа", - "account_tokens_description": "Используйте токены доступа для публикации и подписки через ntfy API чтобы не пересылать данные Вашей учетной записи. Смотрите документацию чтобы узнать больше.", - "account_tokens_table_token_header": "Токен", - "account_tokens_table_label_header": "Название", - "account_tokens_table_last_access_header": "Последний доступ", - "account_tokens_table_expires_header": "Истекает", - "account_tokens_dialog_label": "Название, например Radarr notifications", - "prefs_reservations_title": "Зарезервированные темы", - "prefs_reservations_description": "Здесь Вы можете резервировать темы для личного пользования. Резервирование дает Вам возможность управлять темой и настраивать правила доступа к ней для пользователей.", - "prefs_reservations_limit_reached": "Вы исчерпали Ваш лимит на количество зарезервированных тем.", - "prefs_reservations_add_button": "Добавить тему", - "prefs_reservations_edit_button": "Настройка доступа", - "prefs_reservations_delete_button": "Сбросить правила доступа", - "prefs_reservations_table_everyone_read_only": "Я могу публиковать и подписываться, все остальные могут подписываться", - "prefs_reservations_dialog_access_label": "Доступ", - "reservation_delete_dialog_description": "Удаление резервирования дает возможность зарезервировать эту тему другим. Вы можете оставить или удалить существующие сообщения и вложения.", - "reservation_delete_dialog_action_keep_title": "Сохранить сообщения в кэше и вложения", - "reservation_delete_dialog_submit_button": "Удалить резервирование", - "account_basics_tier_basic": "Базовый", - "nav_upgrade_banner_description": "Зарезервированные темы, больше сообщений и электронных писем, а также вложения большего размера", - "alert_not_supported_context_description": "Уведомления поддерживаются только по протоколу HTTPS. Это ограничение Notifications API.", - "notifications_delete": "Удалить", - "notifications_new_indicator": "Новое уведомление", - "notifications_actions_http_request_title": "Сделать HTTP {{method}}-запрос на {{url}}", - "display_name_dialog_placeholder": "Псевдоним", - "account_basics_password_dialog_new_password_label": "Новый пароль", - "account_basics_password_dialog_confirm_password_label": "Подтвердите пароль", - "account_basics_password_dialog_button_submit": "Сменить пароль", - "account_basics_tier_title": "Тип учетной записи", - "error_boundary_unsupported_indexeddb_description": "Веб-приложение ntfy использует IndexedDB, который не поддерживается Вашим браузером в приватном режиме.

Хотя это и не лучший вариант, использовать веб-приложение ntfy в приватном режиме не имеет особого смысла, так как все данные храняться в локальном хранилище браузера. Вы можете узнать больше в этом отчете на GitHub или связавшись с нами через Discord или Matrix.", - "account_basics_tier_interval_monthly": "ежемесячно", - "account_basics_tier_interval_yearly": "ежегодно", - "account_upgrade_dialog_interval_yearly": "Ежегодно", - "account_upgrade_dialog_interval_yearly_discount_save": "скидка {{discount}}%", - "account_upgrade_dialog_interval_monthly": "Ежемесячно", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "скидка до {{discount}}%", - "account_upgrade_dialog_tier_features_no_reservations": "Нет зарезервированных тем", - "account_upgrade_dialog_tier_price_per_month": "в месяц", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} в год. Оплата помесячно.", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} ежегодно. Сэкономьте {{save}}.", - "account_upgrade_dialog_billing_contact_email": "По вопросам оплаты, пожалуйста свяжитесь с нами.", - "account_upgrade_dialog_billing_contact_website": "По вопросам оплаты, пожалуйста обратитесь к нашему сайту." + "action_bar_logo_alt": "ntfy лого", + "emoji_picker_search_clear": "Очистить поиск" } diff --git a/web/public/static/langs/sv.json b/web/public/static/langs/sv.json index bc4a540..12939f7 100644 --- a/web/public/static/langs/sv.json +++ b/web/public/static/langs/sv.json @@ -14,7 +14,7 @@ "alert_grant_title": "Notiser är avstängda", "alert_grant_button": "Bevilja nu", "alert_not_supported_title": "Notiser stöds inte", - "notifications_list": "Notifieringslista", + "notifications_list": "Notis-lista", "notifications_list_item": "Notis", "notifications_delete": "Radera", "notifications_copied_to_clipboard": "Kopierat till urklipp", @@ -47,338 +47,5 @@ "notifications_actions_open_url_title": "Gå till {{url}}", "notifications_none_for_any_title": "Du har inte fått några notiser.", "notifications_example": "Exempel", - "notifications_loading": "Laddar notiser …", - "signup_title": "Skapa ett nytt konto", - "signup_form_confirm_password": "Bekräfta lösenord", - "signup_form_button_submit": "Skapa konto", - "login_title": "Logga in på ditt konto", - "login_form_button_submit": "Logga in", - "login_link_signup": "Registrera", - "login_disabled": "Inloggning är inaktiverat", - "action_bar_account": "Konto", - "action_bar_change_display_name": "Ändra visningsnamn", - "action_bar_reservation_add": "Reservera ämne", - "action_bar_reservation_edit": "Ändra reservation", - "action_bar_reservation_delete": "Ta bort reservation", - "action_bar_reservation_limit_reached": "Gräns nådd", - "action_bar_profile_title": "Profil", - "action_bar_profile_settings": "Inställningar", - "action_bar_profile_logout": "Logga ut", - "action_bar_sign_in": "Logga in", - "action_bar_sign_up": "Registrera", - "nav_button_account": "Konto", - "nav_upgrade_banner_label": "Uppgradera till Pro", - "common_add": "Lägg till", - "signup_form_password": "Lösenord", - "signup_form_toggle_password_visibility": "Visa/dölj lösenord", - "common_cancel": "Avbryt", - "common_save": "Spara", - "signup_form_username": "Användarnamn", - "signup_already_have_account": "Har du redan ett konto? Logga in!", - "signup_disabled": "Registrering är inaktiverad", - "signup_error_username_taken": "Användarnamn [[username]] används redan", - "notifications_attachment_file_document": "annat dokument", - "notifications_attachment_file_app": "Android app fil", - "notifications_click_copy_url_title": "Kopiera länk till urklipp", - "notifications_none_for_topic_title": "Du har inte fått några notiser för detta ämnet ännu.", - "notifications_none_for_topic_description": "För att kunna skicka notiser till detta ämnet, använd PUT eller POST till ämnets URL.", - "notifications_actions_http_request_title": "Skicka HTTP {{method}} till {{url}}", - "publish_dialog_progress_uploading": "Laddar upp …", - "nav_upgrade_banner_description": "Reservera ämnen, fler meddelanden och e-postmeddelanden och större bilagor", - "publish_dialog_attachment_limits_file_and_quota_reached": "överskrider {{fileSizeLimit}} filgräns och kvot, {{remainingBytes}} återstående", - "publish_dialog_attachment_limits_file_reached": "överskrider {{fileSizeLimit}} filgräns", - "publish_dialog_attachment_limits_quota_reached": "överskrider kvoten, {{remainingBytes}} återstår", - "publish_dialog_message_placeholder": "Skriv ett meddelande här", - "publish_dialog_checkbox_publish_another": "Publicera en till", - "subscribe_dialog_error_user_anonymous": "anonym", - "account_basics_password_dialog_confirm_password_label": "Bekräfta lösenord", - "publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com", - "publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i dokumentationen .", - "publish_dialog_button_send": "Skicka", - "common_back": "Tillbaka", - "account_basics_tier_free": "Gratis", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne", - "account_delete_title": "Ta bort konto", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande", - "account_upgrade_dialog_button_cancel": "Avbryt", - "common_copy_to_clipboard": "Kopiera till urklipp", - "account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat", - "account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i dokumentationen.", - "account_tokens_table_create_token_button": "Skapa åtkomsttoken", - "prefs_users_description_no_sync": "Användare och lösenord synkroniseras inte till ditt konto.", - "error_boundary_unsupported_indexeddb_description": "ntfy-webbappen behöver IndexedDB för att fungera och din webbläsare har inte stöd för IndexedDB i privat surfläge.

Detta är beklagligt, men det är inte heller särskilt meningsfullt att använda ntfy-webbappen i privat surfläge, eftersom allt lagras i webbläsarens lagringsutrymme. Du kan läsa mer om det i detta GitHub-ärende, eller prata med oss på Discord eller Matrix.", - "account_basics_tier_interval_monthly": "månadsvis", - "account_basics_tier_interval_yearly": "årligen", - "account_basics_tier_canceled_subscription": "Din prenumeration avbröts och kommer att nedgraderas till ett gratis konto den {{date}}.", - "account_basics_tier_manage_billing_button": "Hantera fakturering", - "account_usage_messages_title": "Publicerade meddelande", - "account_usage_emails_title": "Skickade e-postmeddelanden", - "account_usage_reservations_title": "Reserverade ämnen", - "account_usage_reservations_none": "Inga reserverade ämnen för det här kontot", - "account_usage_attachment_storage_title": "Lagring av bilagor", - "account_usage_attachment_storage_description": "{{filesize}} per fil, raderas efter {{expiry}}", - "account_delete_description": "Ta bort ditt konto permanent", - "account_delete_dialog_description": "Detta kommer att radera ditt konto permanent, inklusive all data som lagras på servern. Efter raderingen kommer ditt användarnamn att vara otillgängligt i 7 dagar. Om du verkligen vill fortsätta, bekräfta med ditt lösenord i rutan nedan.", - "account_delete_dialog_label": "Lösenord", - "account_delete_dialog_button_cancel": "Avbryt", - "account_delete_dialog_button_submit": "Ta bort kontot permanent", - "account_delete_dialog_billing_warning": "Om du raderar ditt konto annulleras också din faktureringsprenumeration omedelbart. Du kommer inte längre att ha tillgång till instrumentpanelen för fakturering.", - "account_upgrade_dialog_title": "Ändra kontonivå", - "account_upgrade_dialog_interval_monthly": "Månadsvis", - "account_upgrade_dialog_interval_yearly": "Årligen", - "account_upgrade_dialog_interval_yearly_discount_save": "spara {{discount}}%", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "spara upp till {{discount}}%", - "account_upgrade_dialog_cancel_warning": "Detta kommer att säga upp din prenumeration och nedgradera ditt konto på {{date}}. På det datumet kommer ämnesreservationer och meddelanden som ligger i cacheminnet på servern att raderas.", - "account_upgrade_dialog_proration_info": "Deklaration: När du uppgraderar mellan betalda planer kommer prisskillnaden att debiteras omedelbart. Vid nedgradering till en lägre nivå kommer saldot att användas för att betala för framtida faktureringsperioder.", - "account_upgrade_dialog_reservations_warning_one": "Den valda nivån tillåter färre reserverade ämnen än din nuvarande nivå. Innan du ändrar nivå, bör du ta bort minst en reservation. Du kan ta bort reservationer i Inställningar.", - "account_upgrade_dialog_reservations_warning_other": "Den valda nivån tillåter färre reserverade ämnen än din nuvarande nivå. Innan du ändrar nivå, ta bort minst {{count}} reservationer. Du kan ta bort reservationer i Inställningar.", - "account_upgrade_dialog_tier_features_no_reservations": "Inga reserverade ämnen", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per fil", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total lagring", - "account_upgrade_dialog_tier_price_per_month": "månad", - "account_upgrade_dialog_tier_selected_label": "Vald", - "account_tokens_table_token_header": "Token", - "account_tokens_dialog_title_create": "Skapa åtkomsttoken", - "account_tokens_dialog_title_delete": "Ta bort åtkomsttoken", - "account_tokens_dialog_label": "Etikett, t.ex. Radarr-meddelanden", - "account_tokens_dialog_title_edit": "Redigera åtkomsttoken", - "account_tokens_dialog_button_create": "Skapa token", - "account_tokens_dialog_button_update": "Uppdatera token", - "account_tokens_delete_dialog_submit_button": "Ta bort token permanent", - "prefs_notifications_delete_after_one_day": "Efter en dag", - "reservation_delete_dialog_action_delete_description": "Cachade meddelanden och bilagor raderas permanent. Denna åtgärd kan inte ångras.", - "error_boundary_gathering_info": "Samla mer information …", - "error_boundary_unsupported_indexeddb_title": "Privat surfning stöds inte", - "reservation_delete_dialog_submit_button": "Ta bort reservationen", - "priority_low": "låg", - "error_boundary_title": "Åh nej, ntfy kraschade", - "error_boundary_description": "Detta får naturligtvis inte ske. Vi beklagar verkligen detta.
Om du har tid, vänligen rapportera detta på GitHub, eller meddela oss via Discord eller Matrix.", - "notifications_no_subscriptions_title": "Det ser ut som om du inte har några prenumerationer ännu.", - "notifications_more_details": "Mer information finns på webbplatsen eller i dokumentationen .", - "publish_dialog_title_topic": "Publicera till {{topic}}", - "publish_dialog_message_published": "Meddelande publicerat", - "publish_dialog_emoji_picker_show": "Välj emoji", - "publish_dialog_base_url_placeholder": "Service-URL, t.ex. https://example.com", - "publish_dialog_topic_label": "Ämnesnamn", - "publish_dialog_topic_placeholder": "Ämnesnamn, t.ex. phils_alerts", - "publish_dialog_topic_reset": "Återställ ämne", - "publish_dialog_title_label": "Titel", - "publish_dialog_title_placeholder": "Meddelandets rubrik, t.ex. Varning för diskutrymme", - "publish_dialog_tags_label": "Taggar", - "publish_dialog_message_label": "Meddelande", - "publish_dialog_tags_placeholder": "Kommaseparerad lista med taggar, t.ex. warning, srv1-backup", - "publish_dialog_priority_label": "Prioritet", - "publish_dialog_click_label": "Klicka på URL", - "publish_dialog_click_placeholder": "URL som öppnas när man klickar på anmälan", - "publish_dialog_click_reset": "Ta bort klickbar URL", - "publish_dialog_email_reset": "Ta bort vidarebefordran av e-post", - "publish_dialog_attach_label": "URL för bifogade filer", - "publish_dialog_attach_placeholder": "Bifoga fil via URL, t.ex. https://f-droid.org/F-Droid.apk", - "publish_dialog_filename_label": "Filnamn", - "publish_dialog_delay_label": "Fördröjning", - "publish_dialog_filename_placeholder": "Filnamn för bifogad fil", - "publish_dialog_delay_placeholder": "Fördröj leverans, t.ex. {{unixTimestamp}}, {{relativeTime}} eller \"{{naturalLanguage}}\" (endast engelska)", - "publish_dialog_delay_reset": "Ta bort försenad leverans", - "publish_dialog_other_features": "Andra funktioner:", - "publish_dialog_chip_click_label": "Klicka på URL", - "publish_dialog_attached_file_title": "Bifogad fil:", - "publish_dialog_attached_file_filename_placeholder": "Filnamn för bifogad fil", - "emoji_picker_search_placeholder": "Sök emoji", - "subscribe_dialog_subscribe_button_cancel": "Avbryt", - "prefs_notifications_sound_description_some": "Meddelanden spelar upp ljudet {{sound}} när de anländer", - "prefs_notifications_sound_no_sound": "Inget ljud", - "prefs_notifications_min_priority_any": "Alla prioriteringar", - "prefs_notifications_min_priority_low_and_higher": "Låg prioritet och högre", - "prefs_notifications_delete_after_three_hours": "Efter tre timmar", - "prefs_notifications_delete_after_never": "Aldrig", - "prefs_users_table": "Användartabell", - "prefs_users_add_button": "Lägg till användare", - "prefs_users_edit_button": "Redigera användare", - "prefs_users_dialog_title_add": "Lägg till användare", - "prefs_users_dialog_title_edit": "Redigera användare", - "prefs_users_dialog_base_url_label": "Tjänstens URL, t.ex. https://ntfy.sh", - "prefs_users_dialog_password_label": "Lösenord", - "prefs_appearance_title": "Utseende", - "prefs_appearance_language_title": "Språk", - "priority_min": "min", - "priority_default": "standard", - "priority_high": "hög", - "priority_max": "max", - "error_boundary_button_copy_stack_trace": "Kopiera stackspårning", - "error_boundary_stack_trace": "Stackspårning", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverade ämnen", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} dagligt meddelande", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} dagliga e-postmeddelanden", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per år. Faktureras månadsvis.", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} faktureras årligen. Spara {{save}}.", - "account_upgrade_dialog_tier_current_label": "Aktuell", - "account_upgrade_dialog_billing_contact_email": "För faktureringsfrågor, vänligen kontakta oss direkt.", - "account_upgrade_dialog_billing_contact_website": "För frågor om fakturering hänvisar vi till vår webbplats.", - "account_upgrade_dialog_button_redirect_signup": "Registrera dig nu", - "account_upgrade_dialog_button_pay_now": "Betala nu och prenumerera", - "account_upgrade_dialog_button_cancel_subscription": "Avbryt prenumeration", - "account_upgrade_dialog_button_update_subscription": "Uppdatera prenumeration", - "account_tokens_table_label_header": "Etikett", - "account_tokens_table_last_access_header": "Sista åtkomst", - "account_tokens_table_expires_header": "Upphör", - "account_tokens_table_never_expires": "Upphör aldrig", - "account_tokens_table_current_session": "Nuvarande webbläsarsession", - "account_tokens_table_cannot_delete_or_edit": "Det går inte att redigera eller ta bort aktuell sessionstoken", - "account_tokens_table_last_origin_tooltip": "Från IP-adress {{ip}}, klicka för att söka upp", - "account_tokens_dialog_button_cancel": "Avbryt", - "account_tokens_dialog_expires_label": "Åtkomsttoken löper ut om", - "account_tokens_dialog_expires_unchanged": "Lämna utgångsdatumet oförändrat", - "account_tokens_dialog_expires_x_hours": "Token går ut om {{hours}} timmar", - "account_tokens_dialog_expires_x_days": "Token löper ut om {{days}} dagar", - "account_tokens_dialog_expires_never": "Token upphör aldrig att gälla", - "account_tokens_delete_dialog_title": "Ta bort åtkomsttoken", - "account_tokens_delete_dialog_description": "Innan du tar bort en åtkomsttoken bör du se till att inga program eller skript använder den aktivt. Den här åtgärden kan inte ångras.", - "prefs_notifications_title": "Notifieringar", - "prefs_notifications_sound_title": "Ljud för meddelanden", - "prefs_notifications_sound_description_none": "Meddelanden spelar inte upp något ljud när de kommer", - "prefs_notifications_sound_play": "Spela upp valt ljud", - "prefs_notifications_min_priority_title": "Lägsta prioritet", - "prefs_notifications_min_priority_description_any": "Visa alla meddelanden, oavsett prioritet", - "prefs_notifications_min_priority_description_x_or_higher": "Visa meddelanden om prioritet är {{number}} ({{name}}) eller högre", - "prefs_notifications_min_priority_description_max": "Visa notifieringar om prioritet är 5 (max)", - "prefs_notifications_min_priority_default_and_higher": "Standardprioritet och högre", - "prefs_notifications_min_priority_high_and_higher": "Hög prioritet och högre", - "prefs_notifications_min_priority_max_only": "Bara högsta prioritet", - "prefs_notifications_delete_after_title": "Radera meddelanden", - "prefs_notifications_delete_after_one_week": "Efter en vecka", - "prefs_notifications_delete_after_one_month": "Efter en månad", - "prefs_notifications_delete_after_never_description": "Meddelanden raderas aldrig automatiskt", - "prefs_notifications_delete_after_three_hours_description": "Meddelanden raderas automatiskt efter tre timmar", - "prefs_users_description": "Lägg till/ta bort användare för dina skyddade ämnen här. Observera att användarnamn och lösenord lagras i webbläsarens lokala lagring.", - "prefs_users_delete_button": "Ta bort användare", - "prefs_users_table_cannot_delete_or_edit": "Kan inte ta bort eller redigera inloggad användare", - "prefs_users_table_user_header": "Användare", - "prefs_users_table_base_url_header": "Service-URL", - "prefs_users_dialog_username_label": "Användarnamn, t.ex. phil", - "prefs_reservations_title": "Reserverade ämnen", - "prefs_reservations_description": "Du kan reservera ämnesnamn för personligt bruk här. Genom att reservera ett ämne får du äganderätt till ämnet och kan definiera åtkomstbehörigheter för andra användare till ämnet.", - "prefs_reservations_limit_reached": "Du har nått gränsen för reserverade ämnen.", - "prefs_reservations_add_button": "Lägg till reserverat ämne", - "prefs_reservations_dialog_title_edit": "Redigera reserverat ämne", - "prefs_reservations_dialog_title_delete": "Ta bort ämnesreservation", - "signup_error_creation_limit_reached": "Gränsen för skapande av konton har uppnåtts", - "alert_not_supported_context_description": "Meddelanden stöds endast via HTTPS. Detta är en begränsning av Notifications API.", - "notifications_actions_not_supported": "Åtgärd stöds inte i webbapplikationen", - "notifications_none_for_any_description": "För att skicka meddelanden till ett ämne är det bara att PUT eller POST till ämnets URL. Här är ett exempel med ett av dina ämnen.", - "notifications_no_subscriptions_description": "Klicka på länken \"{{linktext}}\" för att skapa eller prenumerera på ett ämne. Därefter kan du skicka meddelanden via PUT eller POST och du får meddelanden här.", - "display_name_dialog_title": "Ändra visningsnamn", - "display_name_dialog_description": "Ange ett alternativt namn för ett ämne som visas i prenumerationslistan. På så sätt kan du lättare identifiera ämnen med komplicerade namn.", - "display_name_dialog_placeholder": "Visningsnamn", - "reserve_dialog_checkbox_label": "Reservera ämne och konfigurera åtkomst", - "publish_dialog_title_no_topic": "Publicera meddelande", - "publish_dialog_progress_uploading_detail": "Laddar upp {{loaded}}/{{{total}} ({{procent}}}%) …", - "publish_dialog_priority_min": "Lägsta prioritet", - "publish_dialog_priority_low": "Låg prioritet", - "publish_dialog_priority_default": "Standard prioritet", - "publish_dialog_priority_high": "Hög prioritet", - "publish_dialog_priority_max": "Högsta prioritet", - "publish_dialog_base_url_label": "Service-URL", - "publish_dialog_email_label": "E-post", - "publish_dialog_attach_reset": "Ta bort URL för bifogade filer", - "publish_dialog_chip_email_label": "Vidarebefordra till e-post", - "publish_dialog_chip_attach_url_label": "Bifoga fil via URL", - "publish_dialog_chip_attach_file_label": "Bifoga lokal fil", - "publish_dialog_chip_delay_label": "Fördröj leveransen", - "publish_dialog_chip_topic_label": "Ändra ämne", - "publish_dialog_button_cancel_sending": "Avbryt sändning", - "publish_dialog_button_cancel": "Avbryt", - "publish_dialog_attached_file_remove": "Ta bort bifogad fil", - "publish_dialog_drop_file_here": "Släpp filen här", - "emoji_picker_search_clear": "Rensa sökning", - "subscribe_dialog_subscribe_title": "Prenumerera på ämnet", - "subscribe_dialog_subscribe_description": "Ämnen kanske inte är lösenordsskyddade, så välj ett namn som inte är lätt att gissa. När du har prenumererat kan du lägga in/lägga in meddelanden.", - "subscribe_dialog_subscribe_topic_placeholder": "Ämnesnamn, t.ex. phils_alerts", - "subscribe_dialog_subscribe_use_another_label": "Använd en annan server", - "subscribe_dialog_subscribe_base_url_label": "Service-URL", - "subscribe_dialog_subscribe_button_generate_topic_name": "Generera namn", - "subscribe_dialog_subscribe_button_subscribe": "Prenumerera", - "subscribe_dialog_login_title": "Inloggning krävs", - "subscribe_dialog_login_description": "Det här ämnet är lösenordsskyddat. Ange användarnamn och lösenord för att prenumerera.", - "subscribe_dialog_login_username_label": "Användarnamn, t.ex. phil", - "subscribe_dialog_login_password_label": "Lösenord", - "subscribe_dialog_login_button_login": "Logga in", - "subscribe_dialog_error_user_not_authorized": "Användaren {{användarnamn}} inte auktoriserad", - "subscribe_dialog_error_topic_already_reserved": "Ämnet är redan reserverat", - "account_basics_title": "Konto", - "account_basics_tier_paid_until": "Prenumerationen är betald fram till {{datum}}, och kommer att förnyas automatiskt", - "account_basics_username_title": "Användarnamn", - "account_basics_username_description": "Hej, det är du ❤", - "account_basics_username_admin_tooltip": "Du är admin", - "account_basics_password_title": "Lösenord", - "account_basics_password_description": "Ändra lösenordet till ditt konto", - "account_basics_tier_payment_overdue": "Din betalning är försenad. Vänligen uppdatera din betalningsmetod, annars kommer ditt konto att nedgraderas inom kort.", - "account_basics_password_dialog_title": "Byt lösenord", - "account_basics_password_dialog_current_password_label": "Aktuellt lösenord", - "account_basics_password_dialog_new_password_label": "Nytt lösenord", - "account_basics_password_dialog_button_submit": "Byt lösenord", - "account_basics_password_dialog_current_password_incorrect": "Felaktigt lösenord", - "account_usage_title": "Användning", - "account_usage_of_limit": "av {{limit}}", - "account_usage_unlimited": "Obegränsad", - "account_usage_limits_reset_daily": "Användningsgränserna återställs dagligen vid midnatt (UTC)", - "account_basics_tier_title": "Kontotyp", - "account_basics_tier_description": "Ditt kontos nivå", - "account_basics_tier_admin": "Admin", - "account_basics_tier_admin_suffix_with_tier": "(med {{tier}}} nivå)", - "account_basics_tier_admin_suffix_no_tier": "(ingen nivå)", - "account_basics_tier_basic": "Grundläggande", - "account_basics_tier_upgrade_button": "Uppgradera till Pro", - "account_basics_tier_change_button": "Ändra", - "account_usage_cannot_create_portal_session": "Det går inte att öppna faktureringsportalen", - "account_usage_basis_ip_description": "Användningsstatistik och begränsningar för det här kontot baseras på din IP-adress, så de kan delas med andra användare. De gränser som visas ovan är ungefärliga och baseras på befintliga gränser.", - "account_tokens_title": "Åtkomsttoken", - "prefs_notifications_delete_after_one_day_description": "Meddelanden raderas automatiskt efter en dag", - "prefs_notifications_delete_after_one_week_description": "Meddelanden raderas automatiskt efter en vecka", - "prefs_notifications_delete_after_one_month_description": "Meddelanden raderas automatiskt efter en månad", - "prefs_users_title": "Hantera användare", - "prefs_reservations_table_not_subscribed": "Prenumererar inte", - "prefs_reservations_table_click_to_subscribe": "Klicka för att prenumerera", - "prefs_reservations_edit_button": "Redigera ämnesåtkomst", - "prefs_reservations_delete_button": "Återställ ämnesåtkomst", - "prefs_reservations_table": "Tabell över reserverade ämnen", - "prefs_reservations_table_topic_header": "Ämne", - "prefs_reservations_table_access_header": "Tillgång", - "prefs_reservations_table_everyone_deny_all": "Endast jag kan publicera och prenumerera", - "prefs_reservations_table_everyone_read_only": "Jag kan publicera och prenumerera, alla kan prenumerera", - "prefs_reservations_table_everyone_write_only": "Jag kan publicera och prenumerera, alla kan publicera", - "prefs_reservations_table_everyone_read_write": "Alla kan publicera och prenumerera", - "prefs_reservations_dialog_title_add": "Reserverade ämnen", - "prefs_reservations_dialog_description": "Genom att reservera ett ämne får du äganderätt till ämnet och kan definiera åtkomstbehörigheter för andra användare till ämnet.", - "prefs_reservations_dialog_topic_label": "Ämne", - "prefs_reservations_dialog_access_label": "Tillgång", - "reservation_delete_dialog_action_keep_title": "Behåll cachade meddelanden och bilagor", - "reservation_delete_dialog_action_keep_description": "Meddelanden och bilagor som lagras på servern blir offentligt synliga för personer som känner till ämnesnamnet.", - "reservation_delete_dialog_action_delete_title": "Ta bort meddelanden och bilagor som sparats i cacheminnet", - "reservation_delete_dialog_description": "Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor.", - "publish_dialog_call_label": "Telefonsamtal", - "publish_dialog_call_reset": "Ta bort telefonsamtal", - "publish_dialog_chip_call_label": "Telefonsamtal", - "account_basics_phone_numbers_title": "Telefonnummer", - "account_basics_phone_numbers_description": "För notifieringar via telefonsamtal", - "account_basics_phone_numbers_no_phone_numbers_yet": "Inga telefonnummer ännu", - "account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer kopierat till urklipp", - "account_basics_phone_numbers_dialog_title": "Lägga till telefonnummer", - "account_basics_phone_numbers_dialog_number_label": "Telefonnummer", - "account_basics_phone_numbers_dialog_number_placeholder": "t.ex. +1222333444", - "account_basics_phone_numbers_dialog_verify_button_sms": "Skicka SMS", - "account_basics_phone_numbers_dialog_verify_button_call": "Ring mig", - "account_basics_phone_numbers_dialog_code_label": "Verifieringskod", - "account_basics_phone_numbers_dialog_channel_call": "Ring", - "account_usage_calls_title": "Telefonsamtal som gjorts", - "account_usage_calls_none": "Inga telefonsamtal kan göras med detta konto", - "publish_dialog_call_item": "Ring telefonnummer {{number}}", - "publish_dialog_chip_call_no_verified_numbers_tooltip": "Inga verifierade telefonnummer", - "account_basics_phone_numbers_dialog_description": "För att använda funktionen för samtalsavisering måste du lägga till och verifiera minst ett telefonnummer. Verifieringen kan göras via SMS eller ett telefonsamtal.", - "account_basics_phone_numbers_dialog_code_placeholder": "t.ex. 123456", - "account_basics_phone_numbers_dialog_check_verification_button": "Bekräfta kod", - "account_basics_phone_numbers_dialog_channel_sms": "SMS", - "account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagliga telefonsamtal", - "account_upgrade_dialog_tier_features_no_calls": "Inga telefonsamtal", - "account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagliga telefonsamtal" + "notifications_loading": "Laddar notiser …" } diff --git a/web/public/static/langs/tr.json b/web/public/static/langs/tr.json index 3eccda8..704d65d 100644 --- a/web/public/static/langs/tr.json +++ b/web/public/static/langs/tr.json @@ -34,7 +34,7 @@ "subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.", "subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil", "subscribe_dialog_login_password_label": "Parola", - "common_back": "Geri", + "subscribe_dialog_login_button_back": "Geri", "subscribe_dialog_login_button_login": "Oturum aç", "subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil", "subscribe_dialog_error_user_anonymous": "anonim", @@ -187,173 +187,5 @@ "notifications_priority_x": "Öncelik {{priority}}", "publish_dialog_email_reset": "E-posta yönlendirmesini kaldır", "prefs_users_edit_button": "Kullanıcıyı düzenle", - "prefs_users_delete_button": "Kullanıcı sil", - "signup_form_confirm_password": "Parolayı doğrula", - "signup_form_button_submit": "Kaydol", - "signup_form_toggle_password_visibility": "Parola görünürlüğünü değiştir", - "signup_already_have_account": "Zaten hesabınız var mı? Oturum açın!", - "signup_disabled": "Kayıt devre dışı bırakıldı", - "signup_error_username_taken": "{{username}} kullanıcı adı zaten alındı", - "signup_error_creation_limit_reached": "Hesap oluşturma sınırına ulaşıldı", - "login_title": "ntfy hesabınızda oturum açın", - "login_form_button_submit": "Oturum aç", - "login_link_signup": "Kaydol", - "login_disabled": "Oturum açma devre dışı bırakıldı", - "action_bar_account": "Hesap", - "action_bar_change_display_name": "Görünen adı değiştir", - "action_bar_reservation_add": "Konuyu ayırt", - "action_bar_reservation_edit": "Ayırtmayı değiştir", - "action_bar_reservation_delete": "Ayırtmayı kaldır", - "action_bar_reservation_limit_reached": "Sınıra ulaşıldı", - "action_bar_sign_in": "Oturum aç", - "action_bar_sign_up": "Kaydol", - "nav_button_account": "Hesap", - "nav_upgrade_banner_label": "ntfy Pro'ya yükselt", - "alert_not_supported_context_description": "Bildirimler yalnızca HTTPS üzerinden desteklenir. Bu, Bildirim API'sinin bir sınırlamasıdır.", - "display_name_dialog_description": "Abonelik listesinde görüntülenen bir konu için farklı bir ad belirleyin. Bu, karmaşık adlara sahip konuların daha kolay tanınmasına yardımcı olur.", - "display_name_dialog_placeholder": "Görünen ad", - "reserve_dialog_checkbox_label": "Konuyu ayırt ve erişimi yapılandır", - "subscribe_dialog_error_topic_already_reserved": "Konu zaten ayırtıldı", - "account_basics_title": "Hesap", - "account_basics_username_title": "Kullanıcı adı", - "account_basics_username_description": "Hey, bu sizsiniz ❤", - "account_basics_username_admin_tooltip": "Siz Yöneticisiniz", - "account_basics_password_title": "Parola", - "account_basics_password_description": "Hesap parolanızı değiştirin", - "account_basics_password_dialog_current_password_label": "Geçerli parola", - "account_basics_password_dialog_title": "Parolayı değiştir", - "account_basics_password_dialog_button_submit": "Parolayı değiştir", - "account_basics_password_dialog_current_password_incorrect": "Parola yanlış", - "account_usage_title": "Kullanım", - "account_usage_of_limit": "/ {{limit}}", - "account_usage_unlimited": "Sınırsız", - "account_usage_limits_reset_daily": "Kullanım sınırları her gün gece yarısında (UTC) sıfırlanır", - "account_basics_tier_title": "Hesap türü", - "account_basics_tier_description": "Hesabınızın güç seviyesi", - "account_basics_tier_admin": "Yönetici", - "account_basics_tier_basic": "Temel", - "account_basics_tier_free": "Ücretsiz", - "account_basics_tier_upgrade_button": "Pro'ya yükselt", - "account_basics_tier_change_button": "Değiştir", - "account_basics_tier_paid_until": "Abonelik {{date}} tarihine kadar ödendi ve otomatik olarak yenilenecek", - "account_basics_tier_admin_suffix_with_tier": "({{tier}} seviyesiyle)", - "account_basics_tier_admin_suffix_no_tier": "(seviye yok)", - "account_basics_tier_manage_billing_button": "Faturalandırmayı yönet", - "account_usage_reservations_title": "Ayırtılan konular", - "account_usage_reservations_none": "Bu hesap için ayırtılan konu yok", - "account_usage_attachment_storage_title": "Ek depolama", - "account_usage_attachment_storage_description": "Dosya başına {{filesize}}, {{expiry}} sonrasında silinir", - "account_usage_cannot_create_portal_session": "Faturalandırma sayfası açılamıyor", - "account_delete_title": "Hesabı sil", - "account_delete_description": "Hesabınızı kalıcı olarak silin", - "account_delete_dialog_description": "Bu işlem, sunucuda depolanan tüm veriler dahil olmak üzere hesabınızı kalıcı olarak silecektir. Silme işleminden sonra kullanıcı adınız 7 gün boyunca kullanılamayacaktır. Gerçekten devam etmek istiyorsanız, lütfen aşağıdaki kutuya parolanızı yazarak onaylayın.", - "account_delete_dialog_button_cancel": "İptal", - "account_delete_dialog_button_submit": "Hesabı kalıcı olarak sil", - "account_delete_dialog_billing_warning": "Hesabınızı silmek, faturalandırma aboneliğinizi de anında iptal eder. Artık faturalandırma sayfasına erişiminiz olmayacak.", - "account_upgrade_dialog_title": "Hesap seviyesini değiştir", - "account_upgrade_dialog_proration_info": "Fiyatlandırma: Ücretli planlar arasında yükseltme yaparken, fiyat farkı hemen tahsil edilecektir. Daha düşük bir seviyeye inildiğinde, bakiye gelecek faturalandırma dönemleri için ödeme yapmak üzere kullanılacaktır.", - "account_upgrade_dialog_reservations_warning_other": "Seçilen seviye, geçerli seviyenizden daha az konu ayırtmaya izin veriyor. Seviyenizi değiştirmeden önce lütfen en az {{count}} ayırtmayı silin. Ayırtmaları Ayarlar sayfasından kaldırabilirsiniz.", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} konu ayırtıldı", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} günlük mesaj", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} günlük e-posta", - "account_upgrade_dialog_tier_features_attachment_file_size": "dosya başına {{filesize}}", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} toplam depolama", - "account_upgrade_dialog_tier_selected_label": "Seçilen", - "account_upgrade_dialog_tier_current_label": "Geçerli", - "account_upgrade_dialog_button_cancel": "İptal", - "account_upgrade_dialog_button_redirect_signup": "Şimdi kaydol", - "account_upgrade_dialog_button_pay_now": "Şimdi öde ve abone ol", - "account_upgrade_dialog_button_cancel_subscription": "Aboneliği iptal et", - "account_tokens_title": "Erişim belirteçleri", - "account_tokens_table_token_header": "Belirteç", - "account_tokens_table_label_header": "Etiket", - "account_tokens_table_current_session": "Geçerli tarayıcı oturumu", - "common_copy_to_clipboard": "Panoya kopyala", - "account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı", - "account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez", - "account_tokens_table_create_token_button": "Erişim belirteci oluştur", - "account_tokens_table_last_origin_tooltip": "{{ip}} IP adresinden, aramak için tıklayın", - "account_tokens_dialog_title_edit": "Erişim belirtecini düzenle", - "account_tokens_table_expires_header": "Süre dolumu", - "account_tokens_table_never_expires": "Asla süresi dolmaz", - "account_tokens_dialog_title_delete": "Erişim belirtecini sil", - "account_tokens_dialog_label": "Etiket, örn. Radarr bildirimleri", - "account_tokens_dialog_button_create": "Belirteç oluştur", - "account_tokens_dialog_button_update": "Belirteci güncelle", - "account_tokens_dialog_button_cancel": "İptal", - "account_tokens_dialog_expires_label": "Erişim belirtecinin süre dolumu", - "account_tokens_dialog_expires_unchanged": "Süre dolumu tarihini değiştirmeden bırak", - "account_tokens_dialog_expires_x_hours": "Belirtecin süresi {{hours}} saat içinde dolacak", - "account_tokens_dialog_expires_x_days": "Belirtecin süresi {{days}} gün içinde dolacak", - "account_tokens_dialog_expires_never": "Belirtecin süresi asla dolmaz", - "account_tokens_delete_dialog_title": "Erişim belirtecini sil", - "account_tokens_delete_dialog_description": "Bir erişim belirtecini silmeden önce, hiçbir uygulamanın veya betiğin onu etkin olarak kullanmadığından emin olun. Bu işlem geri alınamaz.", - "account_tokens_delete_dialog_submit_button": "Belirteci kalıcı olarak sil", - "prefs_users_table_cannot_delete_or_edit": "Oturum açan kullanıcı silinemez veya düzenlenemez", - "prefs_reservations_title": "Ayırtılan konular", - "prefs_reservations_description": "Konu adlarını burada kişisel kullanım için ayırtabilirsiniz. Bir konuyu ayırtmak, size konu üzerinde sahiplik sağlar ve konu üzerinde diğer kullanıcılar için erişim izinleri tanımlamanıza olanak tanır.", - "prefs_reservations_limit_reached": "Ayırtılan konu sınırınıza ulaştınız.", - "prefs_reservations_edit_button": "Konu erişimini düzenle", - "prefs_reservations_table": "Ayırtılan konular tablosu", - "prefs_reservations_table_topic_header": "Konu", - "prefs_reservations_table_access_header": "Erişim", - "prefs_reservations_table_everyone_deny_all": "Yalnızca ben yayınlayabilir ve abone olabilirim", - "prefs_reservations_table_everyone_write_only": "Ben yayınlayabilir ve abone olabilirim, herkes yayınlayabilir", - "prefs_reservations_table_click_to_subscribe": "Abone olmak için tıklayın", - "prefs_reservations_dialog_title_add": "Konuyu ayırt", - "prefs_reservations_dialog_title_edit": "Ayırtılan konuyu düzenle", - "prefs_reservations_dialog_title_delete": "Konu ayırtmasını sil", - "prefs_reservations_dialog_description": "Bir konuyu ayırtmak, size konu üzerinde sahiplik sağlar ve konu üzerinde diğer kullanıcılar için erişim izinleri tanımlamanıza olanak tanır.", - "prefs_reservations_dialog_topic_label": "Konu", - "prefs_reservations_dialog_access_label": "Erişim", - "reservation_delete_dialog_action_keep_title": "Önbelleğe alınan mesajları ve ekleri sakla", - "reservation_delete_dialog_action_keep_description": "Sunucuda önbelleğe alınan mesajlar ve ekler, konu adını bilen kişiler için görülebilir hale gelecektir.", - "reservation_delete_dialog_action_delete_title": "Önbelleğe alınan mesajları ve ekleri sil", - "reservation_delete_dialog_action_delete_description": "Önbelleğe alınan mesajlar ve ekler kalıcı olarak silinecektir. Bu işlem geri alınamaz.", - "reservation_delete_dialog_submit_button": "Ayırtmayı sil", - "signup_title": "ntfy hesabı oluştur", - "signup_form_username": "Kullanıcı adı", - "signup_form_password": "Parola", - "action_bar_profile_title": "Profil", - "action_bar_profile_logout": "Oturumu kapat", - "action_bar_profile_settings": "Ayarlar", - "nav_upgrade_banner_description": "Konuları ayırtma, daha fazla mesaj ve e-posta, daha büyük ekler", - "display_name_dialog_title": "Görünen adı değiştir", - "account_basics_password_dialog_new_password_label": "Yeni parola", - "account_usage_basis_ip_description": "Bu hesabın kullanım istatistikleri ve sınırları IP adresinize dayalıdır, bu nedenle diğer kullanıcılarla paylaşılabilir. Yukarıda gösterilen sınırlar, mevcut hız sınırlarına dayalı olarak yaklaşık değerlerdir.", - "subscribe_dialog_subscribe_button_generate_topic_name": "Ad oluştur", - "account_basics_password_dialog_confirm_password_label": "Parolayı doğrula", - "account_basics_tier_payment_overdue": "Ödemenizin vadesi geçti. Lütfen ödeme yönteminizi güncelleyin, aksi takdirde hesabınızın seviyesi yakında düşürülecektir.", - "account_usage_messages_title": "Yayınlanan mesajlar", - "account_basics_tier_canceled_subscription": "Aboneliğiniz iptal edildi ve {{date}} tarihinde ücretsiz hesap seviyesine düşürülecek.", - "account_usage_emails_title": "Gönderilen e-postalar", - "account_upgrade_dialog_cancel_warning": "Bu, {{date}} tarihinde aboneliğinizi iptal edecek ve hesabınızın seviyesini düşürecektir. Bu tarihte, sunucuda önbelleğe alınan mesajlar ve ayırtılan konular silinecektir.", - "account_delete_dialog_label": "Parola", - "prefs_users_description_no_sync": "Kullanıcılar ve parolalar hesabınızla eşzamanlanmıyor.", - "account_upgrade_dialog_reservations_warning_one": "Seçilen seviye, geçerli seviyenizden daha az konu ayırtmaya izin veriyor. Seviyenizi değiştirmeden önce lütfen en az bir ayırtmayı silin. Ayırtmaları Ayarlar sayfasından kaldırabilirsiniz.", - "account_tokens_dialog_title_create": "Erişim belirteci oluştur", - "account_tokens_description": "ntfy API aracılığıyla yayınlarken ve abone olurken erişim belirteçlerini kullanın, böylece hesap kimlik bilgilerinizi göndermek zorunda kalmazsınız. Daha fazla bilgi edinmek için belgelere bakın.", - "account_upgrade_dialog_button_update_subscription": "Aboneliği güncelle", - "account_tokens_table_last_access_header": "Son erişim", - "prefs_reservations_add_button": "Ayırtılan konu ekle", - "prefs_reservations_delete_button": "Konu erişimini sıfırla", - "prefs_reservations_table_everyone_read_only": "Ben yayınlayabilir ve abone olabilirim, herkes abone olabilir", - "prefs_reservations_table_not_subscribed": "Abone olunmadı", - "prefs_reservations_table_everyone_read_write": "Herkes yayınlayabilir ve abone olabilir", - "reservation_delete_dialog_description": "Ayırtmanın kaldırılması, konu üzerindeki sahiplikten vazgeçer ve başkalarının onu ayırtmasına izin verir. Mevcut mesajları ve ekleri saklayabilir veya silebilirsiniz.", - "account_basics_tier_interval_yearly": "yıllık", - "account_upgrade_dialog_tier_features_no_reservations": "Ayırtılan konu yok", - "account_upgrade_dialog_tier_price_billed_monthly": "Yıllık {{price}}. Aylık faturalandırılır.", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} yıllık olarak faturalandırılır. {{save}} tasarruf edin.", - "account_upgrade_dialog_interval_yearly": "Yıllık", - "account_upgrade_dialog_interval_yearly_discount_save": "%{{discount}} tasarruf edin", - "account_upgrade_dialog_tier_price_per_month": "ay", - "account_upgrade_dialog_billing_contact_email": "Faturalama ile ilgili sorularınız için lütfen doğrudan bizimle iletişime geçin.", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "%{{discount}} kadar tasarruf edin", - "account_upgrade_dialog_interval_monthly": "Aylık", - "account_basics_tier_interval_monthly": "aylık", - "account_upgrade_dialog_billing_contact_website": "Faturalama ile ilgili sorularınız için lütfen web sitemizi ziyaret edin.", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} ayırtılan konu", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} günlük e-posta", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} günlük mesaj" + "prefs_users_delete_button": "Kullanıcı sil" } diff --git a/web/public/static/langs/uk.json b/web/public/static/langs/uk.json index 32a3079..304bd9d 100644 --- a/web/public/static/langs/uk.json +++ b/web/public/static/langs/uk.json @@ -53,7 +53,7 @@ "subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер", "subscribe_dialog_subscribe_base_url_label": "URL служби", "subscribe_dialog_login_password_label": "Пароль", - "common_back": "Назад", + "subscribe_dialog_login_button_back": "Назад", "subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований", "prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні", "prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}", @@ -187,199 +187,5 @@ "priority_low": "низький", "error_boundary_stack_trace": "Трасування стека", "error_boundary_unsupported_indexeddb_title": "Приватний перегляд не підтримується", - "error_boundary_unsupported_indexeddb_description": "Веб-програма ntfy потребує IndexedDB для роботи, а ваш браузер не підтримує IndexedDB у режимі приватного перегляду.

На жаль, використання ntfy web не має сенсу у режимі приватного перегляду, оскільки все зберігається в пам’яті браузера. Ви можете прочитати більше про це у цьому випуску GitHub або поспілкуватися з нами на Discord або Matrix.", - "signup_title": "Створення облікового запису ntfy", - "signup_form_username": "Ім'я користувача", - "signup_form_password": "Пароль", - "signup_form_confirm_password": "Підтвердіть пароль", - "signup_form_button_submit": "Зареєструватися", - "signup_form_toggle_password_visibility": "Перемкнути видимість пароля", - "signup_already_have_account": "Вже маєте обліковий запис? Увійдіть!", - "signup_disabled": "Реєстрацію вимкнено", - "signup_error_username_taken": "Ім'я користувача {{username}} вже зайнято", - "signup_error_creation_limit_reached": "Досягнуто обмеження на створення облікового запису", - "login_title": "Увійдіть до свого облікового запису ntfy", - "login_form_button_submit": "Увійти", - "login_link_signup": "Зареєструватися", - "login_disabled": "Вхід вимкнено", - "action_bar_account": "Обліковий запис", - "action_bar_reservation_add": "Зарезервувати тему", - "action_bar_reservation_edit": "Змінити резервування", - "action_bar_reservation_delete": "Видалити резервування", - "action_bar_reservation_limit_reached": "Досягнуто ліміту", - "action_bar_change_display_name": "Змінити відображувану назву", - "action_bar_profile_title": "Профіль", - "action_bar_profile_settings": "Налаштування", - "action_bar_sign_up": "Зареєструватися", - "nav_button_account": "Обліковий запис", - "nav_upgrade_banner_description": "Резервування тем, більше повідомлень та імейлів, більші вкладення", - "alert_not_supported_context_description": "Сповіщення підтримуються лише через HTTPS. Це обмеження Notifications API.", - "display_name_dialog_title": "Змінити відображувану назву", - "reserve_dialog_checkbox_label": "Зарезервувати тему та налаштувати доступ", - "subscribe_dialog_subscribe_button_generate_topic_name": "Згенерувати назву", - "subscribe_dialog_error_topic_already_reserved": "Тема вже зарезервована", - "account_basics_title": "Обліковий запис", - "account_basics_username_title": "Ім'я користувача", - "account_basics_username_description": "Привіт, це ти ❤", - "account_basics_password_dialog_title": "Змінити пароль", - "account_basics_password_dialog_current_password_label": "Поточний пароль", - "account_basics_password_dialog_new_password_label": "Новий пароль", - "account_basics_password_dialog_confirm_password_label": "Підтвердіть пароль", - "account_basics_password_dialog_button_submit": "Змінити пароль", - "account_basics_password_dialog_current_password_incorrect": "Неправильний пароль", - "account_usage_title": "Використання", - "account_usage_limits_reset_daily": "Ліміти використання скидаються щодня опівночі (UTC)", - "account_basics_tier_title": "Тип облікового запису", - "account_basics_tier_admin": "Адміністратор", - "action_bar_sign_in": "Увійти", - "action_bar_profile_logout": "Вийти", - "nav_upgrade_banner_label": "Оновлення до ntfy Pro", - "display_name_dialog_description": "Задайте альтернативну назву для теми, яка відображатиметься у списку підписок. Це допоможе легше ідентифікувати теми зі складними назвами.", - "display_name_dialog_placeholder": "Відображуване ім'я", - "account_basics_password_title": "Пароль", - "account_basics_username_admin_tooltip": "Ви адміністратор", - "account_basics_tier_interval_monthly": "щомісяця", - "common_copy_to_clipboard": "Скопіювати в буфер обміну", - "account_basics_phone_numbers_title": "Номери телефонів", - "account_basics_phone_numbers_description": "Для сповіщень через телефонні дзвінки", - "account_basics_phone_numbers_no_phone_numbers_yet": "Поки що немає номерів телефонів", - "account_basics_phone_numbers_copied_to_clipboard": "Номер телефону скопійовано в буфер обміну", - "account_basics_phone_numbers_dialog_title": "Додати номер телефону", - "account_basics_phone_numbers_dialog_number_label": "Номер телефону", - "account_basics_phone_numbers_dialog_number_placeholder": "наприклад, +1222333444", - "account_basics_phone_numbers_dialog_verify_button_sms": "Надіслати SMS", - "account_basics_phone_numbers_dialog_verify_button_call": "Зателефонуйте мені", - "account_basics_phone_numbers_dialog_code_label": "Код підтвердження", - "account_basics_phone_numbers_dialog_code_placeholder": "наприклад, 123456", - "account_basics_phone_numbers_dialog_check_verification_button": "Підтвердити код", - "account_basics_phone_numbers_dialog_channel_sms": "SMS", - "account_basics_phone_numbers_dialog_channel_call": "Дзвінок", - "account_basics_tier_interval_yearly": "щороку", - "account_usage_calls_title": "Здійснені телефонні дзвінки", - "account_usage_calls_none": "З цього облікового запису не можна здійснювати телефонні дзвінки", - "account_usage_attachment_storage_title": "Зберігання вкладень", - "account_usage_attachment_storage_description": "{{filesize}} на файл, видаляється після {{expiry}}", - "account_usage_basis_ip_description": "Статистика використання та ліміти для цього облікового запису базуються на вашій IP-адресі, тому вони можуть бути доступні іншим користувачам. Ліміти, показані вище, є приблизними і базуються на існуючих лімітах тарифів.", - "account_usage_cannot_create_portal_session": "Не вдається відкрити білінговий портал", - "account_delete_title": "Видалення облікового запису", - "account_delete_description": "Назавжди видалити свій обліковий запис", - "account_delete_dialog_label": "Пароль", - "account_delete_dialog_button_cancel": "Скасувати", - "account_delete_dialog_button_submit": "Видалити обліковий запис назавжди", - "account_delete_dialog_billing_warning": "Видалення облікового запису також негайно скасовує вашу підписку. Ви більше не матимете доступу до білінгової панелі.", - "account_upgrade_dialog_title": "Зміна рівня облікового запису", - "account_upgrade_dialog_interval_monthly": "Щомісяця", - "account_upgrade_dialog_interval_yearly": "Щорічно", - "account_upgrade_dialog_interval_yearly_discount_save": "економія {{discount}}%", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "економія до {{discount}}%", - "publish_dialog_call_label": "Телефонний дзвінок", - "publish_dialog_call_placeholder": "Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \"yes\"", - "publish_dialog_chip_call_label": "Телефонний дзвінок", - "publish_dialog_call_reset": "Видалити телефонний дзвінок", - "account_basics_phone_numbers_dialog_description": "Щоб користуватися функцією сповіщення про дзвінки, потрібно додати та верифікувати принаймні один телефонний номер. Верифікацію можна здійснити за допомогою SMS або телефонного дзвінка.", - "account_delete_dialog_description": "Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.", - "account_basics_tier_upgrade_button": "Оновлення до Pro", - "account_basics_password_description": "Зміна пароля облікового запису", - "account_usage_of_limit": "з {{limit}}", - "account_usage_unlimited": "Без обмежень", - "account_basics_tier_description": "Рівень потужності вашого облікового запису", - "account_basics_tier_admin_suffix_with_tier": "(з рівнем {{tier}})", - "account_basics_tier_admin_suffix_no_tier": "(без рівня)", - "account_basics_tier_basic": "Базовий", - "account_basics_tier_free": "Безкоштовний", - "account_basics_tier_change_button": "Змінити", - "account_basics_tier_paid_until": "Підписка оплачена до {{date}} і буде автоматично поновлюватися", - "account_basics_tier_payment_overdue": "Ваш платіж прострочено. Будь ласка, оновіть спосіб оплати, інакше ваш обліковий запис буде знижено до нижчого рівня.", - "account_basics_tier_canceled_subscription": "Вашу підписку було скасовано, і з {{date}} вона буде знижена до безкоштовного акаунта.", - "account_basics_tier_manage_billing_button": "Керувати рахунками", - "account_usage_messages_title": "Опубліковані повідомлення", - "account_usage_emails_title": "Надіслані електронні листи", - "account_usage_reservations_title": "Зарезервовані теми", - "account_usage_reservations_none": "Для цього облікового запису немає зарезервованих тем", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} загальне сховище", - "account_upgrade_dialog_tier_current_label": "Поточний", - "account_upgrade_dialog_tier_selected_label": "Вибране", - "account_upgrade_dialog_cancel_warning": "Це скасує вашу підписку і знизить версію вашого облікового запису {{date}}. У цю дату резервування тем, а також повідомлення, кешовані на сервері , буде видалено.", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервовані теми", - "account_upgrade_dialog_tier_features_no_reservations": "Немає зарезервованих тем", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} повідомлень в день", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} електронний лист в день", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} електронних листів в день", - "account_upgrade_dialog_tier_features_calls_one": "{{calls}} телефонний дзвінок в день", - "account_upgrade_dialog_tier_features_calls_other": "{{дзвінки}} телефонних дзвінків в день", - "account_upgrade_dialog_tier_features_no_calls": "Без телефонних дзвінків", - "account_upgrade_dialog_tier_price_per_month": "місяць", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} на рік. Рахунок виставляється щомісяця.", - "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} виставляється щорічно. Збережіть {{save}}.", - "account_upgrade_dialog_billing_contact_email": "Якщо у вас виникли запитання щодо оплати, зв’яжіться з нами безпосередньо.", - "account_upgrade_dialog_billing_contact_website": "Якщо у вас виникли запитання щодо оплати, відвідайте наш веб-сайт.", - "account_upgrade_dialog_button_cancel_subscription": "Скасувати підписку", - "account_upgrade_dialog_button_update_subscription": "Оновити підписку", - "account_tokens_title": "Токени доступу", - "account_tokens_table_expires_header": "Термін дії закінчується", - "account_tokens_description": "Використовуйте токени доступу при публікації та підписці через ntfy API, щоб не надсилати свої облікові дані. Ознайомтеся з документацією, щоб дізнатися більше.", - "account_tokens_table_token_header": "Токен", - "account_tokens_table_never_expires": "Ніколи не закінчується", - "account_tokens_table_label_header": "Мітка", - "account_tokens_table_current_session": "Поточний сеанс браузера", - "account_tokens_table_last_access_header": "Останній доступ", - "account_tokens_table_copied_to_clipboard": "Токен доступу скопійовано", - "account_tokens_table_cannot_delete_or_edit": "Неможливо редагувати або видалити токен поточного сеансу", - "account_tokens_table_create_token_button": "Створити токен доступу", - "account_tokens_table_last_origin_tooltip": "З IP-адреси {{ip}} натисніть для пошуку", - "account_tokens_dialog_title_create": "Створити токен доступу", - "account_tokens_dialog_button_cancel": "Скасувати", - "account_tokens_dialog_title_edit": "Редагувати токен доступу", - "account_tokens_dialog_title_delete": "Видалити токен доступу", - "account_tokens_dialog_label": "Мітка, наприклад, сповіщення Radarr", - "account_tokens_dialog_button_create": "Створити токен", - "account_tokens_dialog_button_update": "Оновити токен", - "account_tokens_dialog_expires_label": "Термін дії токену доступу закінчується через", - "account_tokens_dialog_expires_x_hours": "Термін дії токена закінчується через {{hours}} годин", - "account_tokens_dialog_expires_x_days": "Термін дії токена закінчується через {{days}} днів", - "account_tokens_delete_dialog_description": "Перш ніж видалити токен доступу, переконайтеся, що жодна програма або скрипт не використовує його. Ця дія не може бути скасована.", - "prefs_users_description_no_sync": "Користувачі та паролі не синхронізуються з вашим акаунтом.", - "prefs_users_table_cannot_delete_or_edit": "Неможливо видалити або відредагувати користувача, який увійшов у систему", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} зарезервована тема", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} повідомлення в день", - "account_tokens_dialog_expires_unchanged": "Залишити термін придатності без змін", - "account_tokens_dialog_expires_never": "Термін дії токена ніколи не закінчується", - "account_tokens_delete_dialog_title": "Видалити токен доступу", - "account_tokens_delete_dialog_submit_button": "Видалити токен назавжди", - "account_upgrade_dialog_proration_info": "Пропорція: При переході з одного тарифного плану на інший різниця в ціні буде списана негайно. При переході на нижчий рівень залишок коштів буде використано для оплати майбутніх розрахункових періодів.", - "account_upgrade_dialog_reservations_warning_one": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, будь ласка, видаліть принаймні одне резервування. Ви можете видалити резервування в Налаштуваннях.", - "account_upgrade_dialog_reservations_warning_other": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, будь ласка, видаліть принаймні {{count}} резервувань. Ви можете видалити резервування в Налаштуваннях.", - "account_upgrade_dialog_button_cancel": "Скасувати", - "account_upgrade_dialog_button_redirect_signup": "Зареєструватися зараз", - "account_upgrade_dialog_button_pay_now": "Оплатити зараз і підписатися", - "prefs_reservations_add_button": "Додати зарезервовану тему", - "prefs_reservations_edit_button": "Редагувати доступ до теми", - "prefs_reservations_limit_reached": "Ви досягли ліміту зарезервованих тем.", - "prefs_reservations_table_click_to_subscribe": "Натисніть, щоб підписатися", - "prefs_reservations_table_topic_header": "Тема", - "prefs_reservations_description": "Тут ви можете зарезервувати назви тем для особистого користування. Резервування теми дає вам право власності на тему і дозволяє визначати права доступу до неї інших користувачів.", - "prefs_reservations_table": "Таблиця зарезервованих тем", - "prefs_reservations_table_access_header": "Доступ", - "prefs_reservations_table_everyone_deny_all": "Тільки я можу публікувати та підписуватись", - "prefs_reservations_table_everyone_read_only": "Я можу публікувати та підписуватись, кожен може підписатися", - "prefs_reservations_table_everyone_write_only": "Я можу публікувати і підписуватися, кожен може публікувати", - "prefs_reservations_table_everyone_read_write": "Кожен може публікувати та підписуватися", - "prefs_reservations_table_not_subscribed": "Не підписаний", - "prefs_reservations_dialog_title_add": "Зарезервувати тему", - "prefs_reservations_dialog_title_edit": "Редагувати зарезервовану тему", - "prefs_reservations_title": "Зарезервовані теми", - "prefs_reservations_delete_button": "Скинути доступ до теми", - "prefs_reservations_dialog_description": "Резервування теми дає вам право власності на цю тему і дозволяє визначати права доступу до неї інших користувачів.", - "prefs_reservations_dialog_topic_label": "Тема", - "prefs_reservations_dialog_access_label": "Доступ", - "reservation_delete_dialog_description": "Видалення резервування позбавляє вас права власності на тему і дозволяє іншим зарезервувати її. Ви можете зберегти або видалити існуючі повідомлення і вкладення.", - "reservation_delete_dialog_submit_button": "Видалити резервування", - "publish_dialog_call_item": "Телефонувати за номером {{номер}}", - "publish_dialog_chip_call_no_verified_numbers_tooltip": "Немає підтверджених номерів телефонів", - "prefs_reservations_dialog_title_delete": "Видалити резервування теми", - "reservation_delete_dialog_action_delete_title": "Видалення кешованих повідомлень і вкладень", - "reservation_delete_dialog_action_keep_title": "Збереження кешованих повідомлень і вкладень", - "reservation_delete_dialog_action_keep_description": "Повідомлення і вкладення, які кешуються на сервері, стають загальнодоступними для людей, які знають назву теми.", - "reservation_delete_dialog_action_delete_description": "Кешовані повідомлення та вкладення будуть видалені назавжди. Ця дія не може бути скасована." + "error_boundary_unsupported_indexeddb_description": "Веб-програма ntfy потребує IndexedDB для роботи, а ваш браузер не підтримує IndexedDB у режимі приватного перегляду.

На жаль, використання ntfy web не має сенсу у режимі приватного перегляду, оскільки все зберігається в пам’яті браузера. Ви можете прочитати більше про це у цьому випуску GitHub або поспілкуватися з нами на Discord або Matrix." } diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json index 2db95f5..945c0eb 100644 --- a/web/public/static/langs/zh_Hans.json +++ b/web/public/static/langs/zh_Hans.json @@ -103,7 +103,7 @@ "subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。", "subscribe_dialog_login_username_label": "用户名,例如 phil", "subscribe_dialog_login_password_label": "密码", - "common_back": "返回", + "subscribe_dialog_login_button_back": "返回", "subscribe_dialog_login_button_login": "登录", "subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户", "subscribe_dialog_error_user_anonymous": "匿名", @@ -187,170 +187,5 @@ "publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup", "publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅文档。", "subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。", - "publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)", - "account_usage_basis_ip_description": "此帐户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。", - "account_usage_cannot_create_portal_session": "无法打开计费门户", - "account_delete_title": "删除帐户", - "account_delete_description": "永久删除您的帐户", - "signup_error_username_taken": "用户名 {{username}} 已被占用", - "signup_error_creation_limit_reached": "已达到帐户创建限制", - "login_title": "请登录你的 ntfy 帐户", - "action_bar_change_display_name": "更改显示名称", - "action_bar_reservation_add": "保留主题", - "action_bar_reservation_delete": "移除保留", - "action_bar_reservation_limit_reached": "达到限制", - "action_bar_profile_title": "个人资料", - "action_bar_profile_settings": "设置", - "action_bar_profile_logout": "登出", - "action_bar_sign_in": "登录", - "action_bar_sign_up": "注册", - "nav_button_account": "帐户", - "nav_upgrade_banner_label": "升级到 ntfy Pro", - "nav_upgrade_banner_description": "保留主题,更多消息和邮件,以及更大的附件", - "alert_not_supported_context_description": "通知仅支持 HTTPS。这是 Notifications API 的限制。", - "display_name_dialog_title": "更改显示名称", - "display_name_dialog_description": "为订阅列表中显示的主题设置一个替代名称。这有助于更轻松地识别名称复杂的主题。", - "display_name_dialog_placeholder": "显示名称", - "reserve_dialog_checkbox_label": "保留主题并配置访问", - "subscribe_dialog_subscribe_button_generate_topic_name": "生成名称", - "account_basics_username_description": "嘿,那是你 ❤", - "account_basics_password_description": "更改您的帐户密码", - "account_basics_password_dialog_title": "更改密码", - "account_basics_password_dialog_current_password_label": "当前密码", - "account_basics_password_dialog_new_password_label": "新密码", - "account_basics_password_dialog_confirm_password_label": "确认密码", - "account_basics_password_dialog_button_submit": "更改密码", - "account_basics_password_dialog_current_password_incorrect": "密码错误", - "account_usage_title": "使用量", - "account_usage_of_limit": "{{limit}} 的", - "account_usage_unlimited": "无限", - "account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置", - "account_basics_tier_title": "帐户类型", - "account_basics_tier_description": "您帐户的权限级别", - "account_basics_tier_admin": "管理员", - "account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等级)", - "account_basics_tier_admin_suffix_no_tier": "(无等级)", - "account_basics_tier_basic": "基础版", - "account_basics_tier_free": "免费", - "account_basics_tier_upgrade_button": "升级到专业版", - "account_basics_tier_change_button": "改变", - "account_basics_tier_paid_until": "订阅已支付至 {{date}},并将自动续订", - "account_basics_tier_manage_billing_button": "管理计费", - "account_usage_messages_title": "已发布消息", - "account_usage_emails_title": "已发送电子邮件", - "account_usage_reservations_title": "保留主题", - "account_usage_reservations_none": "此帐户没有保留主题", - "account_usage_attachment_storage_title": "附件存储", - "account_usage_attachment_storage_description": "每个文件 {{filesize}},在 {{expiry}} 后删除", - "account_upgrade_dialog_button_pay_now": "立即付款并订阅", - "account_upgrade_dialog_button_cancel_subscription": "取消订阅", - "account_upgrade_dialog_button_update_subscription": "更新订阅", - "account_tokens_dialog_title_create": "创建访问令牌", - "account_tokens_dialog_title_edit": "编辑访问令牌", - "account_tokens_dialog_title_delete": "删除访问令牌", - "account_tokens_dialog_button_cancel": "取消", - "account_tokens_dialog_expires_label": "访问令牌过期于", - "account_tokens_dialog_expires_unchanged": "保持过期日期不变", - "account_tokens_dialog_expires_x_hours": "令牌在 {{hours}} 小时后过期", - "account_tokens_dialog_expires_x_days": "令牌在 {{days}} 天后过期", - "account_tokens_dialog_expires_never": "令牌永不过期", - "account_tokens_delete_dialog_title": "删除访问令牌", - "account_tokens_delete_dialog_description": "在删除访问令牌之前,请确保没有应用程序或脚本正在活跃使用它。 此操作无法撤消。", - "account_tokens_delete_dialog_submit_button": "永久删除令牌", - "prefs_users_description_no_sync": "用户和密码不会同步到您的帐户。", - "prefs_users_table_cannot_delete_or_edit": "无法删除或编辑已登录用户", - "prefs_reservations_title": "保留主题", - "prefs_reservations_description": "您可以在此处保留主题名称供个人使用。保留主题使您拥有该主题的所有权,并允许您为其他用户定义对该主题的访问权限。", - "prefs_reservations_limit_reached": "您已达到保留主题限制。", - "prefs_reservations_add_button": "添加保留主题", - "prefs_reservations_edit_button": "编辑主题访问", - "prefs_reservations_delete_button": "重置主题访问", - "prefs_reservations_table": "保留主题表格", - "prefs_reservations_table_topic_header": "主题", - "prefs_reservations_table_access_header": "访问", - "prefs_reservations_table_everyone_deny_all": "只有我可以发布和订阅", - "prefs_reservations_table_everyone_read_only": "我可以发布和订阅,每个人都可以订阅", - "prefs_reservations_table_everyone_write_only": "我可以发布和订阅,每个人都可以发布", - "prefs_reservations_table_everyone_read_write": "每个人都可以发布和订阅", - "prefs_reservations_table_not_subscribed": "未订阅", - "prefs_reservations_table_click_to_subscribe": "点击以订阅", - "prefs_reservations_dialog_title_add": "保留主题", - "prefs_reservations_dialog_title_edit": "编辑保留主题", - "prefs_reservations_dialog_title_delete": "删除主题保留", - "prefs_reservations_dialog_description": "保留主题使您拥有该主题的所有权,并允许您为其他用户定义对该主题的访问权限。", - "prefs_reservations_dialog_topic_label": "主题", - "prefs_reservations_dialog_access_label": "访问", - "reservation_delete_dialog_description": "删除保留会放弃对该主题的所有权,并允许其他人保留它。您可以保留或删除现有邮件和附件。", - "reservation_delete_dialog_action_keep_title": "保留缓存的邮件和附件", - "reservation_delete_dialog_action_keep_description": "缓存在服务器上的消息和附件将对知道主题名称的人公开可见。", - "reservation_delete_dialog_action_delete_title": "删除缓存的邮件和附件", - "reservation_delete_dialog_action_delete_description": "缓存的邮件和附件将被永久删除。此操作无法撤消。", - "reservation_delete_dialog_submit_button": "删除保留", - "account_delete_dialog_description": "这将永久删除您的帐户,包括存储在服务器上的所有数据。删除后,您的用户名将在 7 天内不可用。如果您真的想继续,请在下面的框中使用您的密码进行确认。", - "account_delete_dialog_label": "密码", - "account_delete_dialog_button_cancel": "取消", - "account_delete_dialog_button_submit": "永久删除帐户", - "account_delete_dialog_billing_warning": "删除您的帐户也会立即取消您的计费订阅。您将无法再访问计费仪表板。", - "account_upgrade_dialog_title": "更改帐户等级", - "account_upgrade_dialog_cancel_warning": "这将取消您的订阅,并在 {{date}} 降级您的帐户。在那一天,主题保留以及缓存在服务器上的消息将被删除。", - "account_upgrade_dialog_proration_info": "按比例分配:在付费计划之间升级时,差价将被立刻收取。在降级到较低级别时,余额将被用于支付未来的账单周期。", - "account_upgrade_dialog_reservations_warning_one": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,请至少删除 1 项保留。您可以在设置中删除保留。", - "account_upgrade_dialog_reservations_warning_other": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,请至少删除 {{count}} 项保留。您可以在设置中删除保留。", - "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} 条保留主题", - "account_upgrade_dialog_tier_features_messages_other": "{{messages}} 条每日消息", - "account_upgrade_dialog_tier_features_emails_other": "{{emails}} 条每日邮件", - "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} 每个文件", - "signup_form_confirm_password": "确认密码", - "signup_form_button_submit": "注册", - "signup_form_toggle_password_visibility": "切换密码可见性", - "signup_title": "创建一个 ntfy 帐户", - "signup_form_username": "用户名", - "signup_form_password": "密码", - "signup_already_have_account": "已有帐户?登录!", - "signup_disabled": "注册已禁用", - "login_form_button_submit": "登录", - "login_link_signup": "注册", - "login_disabled": "登录已禁用", - "action_bar_account": "帐户", - "action_bar_reservation_edit": "更改保留", - "subscribe_dialog_error_topic_already_reserved": "主题已保留", - "account_basics_title": "帐户", - "account_basics_username_title": "用户名", - "account_basics_username_admin_tooltip": "你是管理员", - "account_basics_password_title": "密码", - "account_basics_tier_payment_overdue": "您的付款已逾期。请更新您的付款方式,否则您的帐户将很快被降级。", - "account_basics_tier_canceled_subscription": "您的订阅已取消,并将在 {{date}} 降级为免费帐户。", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 总存储空间", - "account_upgrade_dialog_tier_selected_label": "已选", - "account_upgrade_dialog_tier_current_label": "当前", - "account_upgrade_dialog_button_cancel": "取消", - "account_upgrade_dialog_button_redirect_signup": "立即注册", - "account_tokens_title": "访问令牌", - "account_tokens_description": "通过 ntfy API 发布和订阅时使用访问令牌,因此您不必发送您的帐户凭据。查看文档以了解更多信息。", - "account_tokens_table_token_header": "令牌", - "account_tokens_table_label_header": "标签", - "account_tokens_table_last_access_header": "最后访问", - "account_tokens_table_expires_header": "过期", - "account_tokens_table_never_expires": "永不过期", - "account_tokens_table_current_session": "当前浏览器会话", - "common_copy_to_clipboard": "复制到剪贴板", - "account_tokens_table_copied_to_clipboard": "已复制访问令牌", - "account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌", - "account_tokens_table_create_token_button": "创建访问令牌", - "account_tokens_table_last_origin_tooltip": "于IP地址 {{ip}},点击查找", - "account_tokens_dialog_label": "标签,例如:Radarr 通知", - "account_tokens_dialog_button_create": "创建令牌", - "account_tokens_dialog_button_update": "更新令牌", - "account_basics_tier_interval_monthly": "每月", - "account_basics_tier_interval_yearly": "每年", - "account_upgrade_dialog_interval_monthly": "每月", - "account_upgrade_dialog_interval_yearly": "每年", - "account_upgrade_dialog_interval_yearly_discount_save": "节省 {{discount}}%", - "account_upgrade_dialog_interval_yearly_discount_save_up_to": "节省高达 {{discount}}%", - "account_upgrade_dialog_tier_features_no_reservations": "无保留主题", - "account_upgrade_dialog_tier_price_per_month": "月", - "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月计费。", - "account_upgrade_dialog_tier_price_billed_yearly": "{{价格}} 按年计费。节省 {{save}}。", - "account_upgrade_dialog_billing_contact_email": "有关账单问题,请直接联系我们 。", - "account_upgrade_dialog_billing_contact_website": "有关账单问题,请参考我们的网站 。" + "publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)" } diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json index aafc28e..396a876 100644 --- a/web/public/static/langs/zh_Hant.json +++ b/web/public/static/langs/zh_Hant.json @@ -3,7 +3,7 @@ "action_bar_unsubscribe": "取消訂閱", "action_bar_toggle_mute": "通知靜音/解除通知靜音", "action_bar_toggle_action_menu": "開啟/關閉操作選單", - "message_bar_type_message": "在這輸入訊息", + "message_bar_type_message": "在這邊輸入訊息", "alert_grant_description": "允許瀏覽器權限以顯示桌面通知。", "alert_grant_button": "允許", "notifications_list": "通知清單", @@ -70,7 +70,7 @@ "subscribe_dialog_subscribe_button_subscribe": "訂閱", "emoji_picker_search_clear": "清除", "subscribe_dialog_login_password_label": "密碼", - "common_back": "返回", + "subscribe_dialog_login_button_back": "返回", "subscribe_dialog_login_button_login": "登入", "prefs_notifications_delete_after_never": "從不", "prefs_users_add_button": "新增使用者", @@ -81,121 +81,5 @@ "error_boundary_title": "歐買尬,ntfy 壞掉了", "notifications_none_for_any_description": "要開始發送通知到一個主題,只需要對主題 URL 發送 HTTP PUT 或者 POST,例如:", "notifications_no_subscriptions_description": "點選 「{{linktext}}」 連結以建立或訂閱主題。完成後,你就可以使用 HTTP PUT 或者 POST 發送通知到這裡了!", - "error_boundary_description": "很抱歉 ntfy 發生錯誤了。
如果你有時間,煩請到 Github 回報錯誤,或者到 Discord 或者 Matrix 聊天室裡面告訴我們。", - "publish_dialog_tags_placeholder": "逗號分隔的標籤,例如 e.g. warning, srv1-backup", - "publish_dialog_click_label": "點擊網址", - "publish_dialog_attach_placeholder": "從網址新增附件,例如 https://f-droid.org/F-Droid.apk", - "publish_dialog_attach_reset": "移除附件網址", - "publish_dialog_attach_label": "附件網址", - "publish_dialog_delay_reset": "移除延遲傳送", - "publish_dialog_delay_label": "延遲", - "publish_dialog_other_features": "其他功能:", - "publish_dialog_filename_placeholder": "附件檔案名稱", - "publish_dialog_delay_placeholder": "延遲傳送,例如 {{unixTimestamp}}, {{relativeTime}} 或 \"{{naturalLanguage}}\" (僅限英文)", - "publish_dialog_chip_click_label": "點擊網址", - "publish_dialog_chip_email_label": "轉發到電郵", - "publish_dialog_chip_attach_url_label": "從網址新增附件", - "emoji_picker_search_placeholder": "搜尋 emoji", - "subscribe_dialog_subscribe_title": "訂閱主題", - "subscribe_dialog_error_user_not_authorized": "用戶 {{username}} 沒有權限", - "subscribe_dialog_error_user_anonymous": "匿名", - "login_title": "登入 ntfy 帳戶", - "action_bar_reservation_add": "保留主題", - "action_bar_profile_logout": "登出", - "alert_not_supported_context_description": "訊息只支援 HTTPS. 這是受 Notifications API 的限制", - "publish_dialog_base_url_placeholder": "服務網址,例如 https://example.com", - "signup_title": "創建 ntfy 賬戶", - "signup_form_username": "用戶名稱", - "signup_form_password": "密碼", - "signup_form_button_submit": "註冊", - "signup_form_toggle_password_visibility": "顯示/隱藏密碼", - "signup_disabled": "註冊已停止", - "signup_error_username_taken": "用戶名稱 {{username}} 已被取用", - "signup_error_creation_limit_reached": "註冊賬戶限制", - "login_form_button_submit": "登入", - "login_link_signup": "註冊", - "signup_already_have_account": "已有帳戶? 立即登入!", - "login_disabled": "登入已停止", - "action_bar_account": "帳戶", - "action_bar_change_display_name": "改變顯示名稱", - "action_bar_reservation_edit": "改變已保留", - "action_bar_reservation_delete": "移除保留", - "action_bar_reservation_limit_reached": "達到限制", - "action_bar_profile_title": "簡介", - "action_bar_profile_settings": "設置", - "action_bar_sign_in": "登入", - "action_bar_sign_up": "註冊", - "nav_button_account": "帳戶", - "nav_upgrade_banner_label": "升級到 ntfy 專業版", - "nav_upgrade_banner_description": "保留主題,更多信息電郵及附件", - "display_name_dialog_title": "改變顯示名稱", - "display_name_dialog_description": "為主題新增在訂閱清單顯示的第二名稱, 這會令尋找複雜主題時更方便。", - "display_name_dialog_placeholder": "顯示名稱", - "reserve_dialog_checkbox_label": "保留主題及設置權限", - "publish_dialog_progress_uploading_detail": "上載中 {{loaded}}/{{total}} ({{percent}}%) …", - "publish_dialog_message_published": "已公佈通訊", - "publish_dialog_attachment_limits_file_reached": "超出檔案限制 {fileSizeLimit}}", - "publish_dialog_attachment_limits_quota_reached": "超出限制, 尚餘 {{remainingBytes}}", - "publish_dialog_emoji_picker_show": "選擇 emoji", - "publish_dialog_priority_min": "最低優先", - "publish_dialog_priority_low": "較低優先", - "publish_dialog_priority_default": "正常優先", - "publish_dialog_priority_high": "高度優先", - "publish_dialog_priority_max": "最高優先", - "publish_dialog_base_url_label": "服務網址", - "publish_dialog_topic_label": "主題名稱", - "publish_dialog_topic_placeholder": "主題名稱,例如 phil_alerts", - "publish_dialog_topic_reset": "重置主題", - "publish_dialog_title_label": "標題", - "publish_dialog_title_placeholder": "通訊標題,例如 Disk space alert", - "publish_dialog_message_label": "訊息", - "publish_dialog_message_placeholder": "這裏輸入訊息", - "publish_dialog_tags_label": "標籤", - "publish_dialog_click_placeholder": "通訊被點擊時到訪的網址", - "publish_dialog_click_reset": "移除點擊網址", - "publish_dialog_email_reset": "移除電郵轉發", - "publish_dialog_chip_attach_file_label": "上載檔案", - "publish_dialog_chip_delay_label": "延遲傳送", - "publish_dialog_chip_topic_label": "更變主題", - "publish_dialog_details_examples_description": "可以在 documentation 找到詳細的功能說明及例子。", - "publish_dialog_checkbox_publish_another": "公佈更多", - "publish_dialog_attached_file_title": "附件:", - "publish_dialog_attached_file_filename_placeholder": "附件名稱", - "subscribe_dialog_subscribe_use_another_label": "使用另一個伺服器", - "subscribe_dialog_subscribe_base_url_label": "服務網址", - "subscribe_dialog_subscribe_button_generate_topic_name": "生成名稱", - "subscribe_dialog_login_title": "需要登入", - "subscribe_dialog_login_username_label": "用戶名稱,例如 phil", - "subscribe_dialog_error_topic_already_reserved": "主題已被保留", - "account_basics_title": "帳戶", - "account_basics_username_title": "用戶名稱", - "account_basics_username_description": "這就是你了❤", - "account_basics_username_admin_tooltip": "你是管理員", - "account_basics_password_title": "密碼", - "account_basics_password_description": "更變你的密碼", - "account_basics_password_dialog_title": "更變密碼", - "account_basics_password_dialog_new_password_label": "新的密碼", - "account_basics_password_dialog_confirm_password_label": "確認密碼", - "account_basics_password_dialog_button_submit": "更變密碼", - "account_usage_unlimited": "無限制", - "account_usage_title": "已經使用", - "account_usage_limits_reset_daily": "使用限制每天午夜重置", - "account_basics_tier_title": "帳戶類型", - "account_basics_tier_description": "你的能量值", - "account_basics_tier_admin": "管理員", - "account_basics_tier_admin_suffix_with_tier": "(擁有 {{tier}})", - "account_basics_tier_admin_suffix_no_tier": "(無層)", - "account_basics_tier_basic": "基礎", - "account_basics_tier_free": "免費", - "account_basics_tier_upgrade_button": "升級至專業版", - "publish_dialog_email_placeholder": "轉發到電郵,例如 phil@example.com", - "subscribe_dialog_subscribe_topic_placeholder": "主題名稱,例如 phil_alerts", - "publish_dialog_attached_file_remove": "移除附件", - "subscribe_dialog_subscribe_description": "主題可能不受到密碼保護, 所以盡量選擇一個不會容易被猜中的主題名稱。 一旦已訂閱,你能夠 PUT/POST 通訊。", - "subscribe_dialog_login_description": "這個主題受密碼保護,請輸入用戶名稱及密碼以訂閱主題。", - "account_basics_password_dialog_current_password_label": "現在的密碼", - "account_basics_password_dialog_current_password_incorrect": "密碼不正確", - "account_basics_tier_change_button": "更變", - "common_add": "新增", - "signup_form_confirm_password": "確認密碼" + "error_boundary_description": "很抱歉 ntfy 發生錯誤了。
如果你有時間,煩請到 Github 回報錯誤,或者到 Discord 或者 Matrix 聊天室裡面告訴我們。" } diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 9576c4e..6382d1f 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -1,430 +1,382 @@ -import i18n from "i18next"; import { - accountBillingPortalUrl, - accountBillingSubscriptionUrl, - accountPasswordUrl, - accountPhoneUrl, - accountPhoneVerifyUrl, - accountReservationSingleUrl, - accountReservationUrl, - accountSettingsUrl, - accountSubscriptionUrl, - accountTokenUrl, - accountUrl, - maybeWithBearerAuth, - tiersUrl, - withBasicAuth, - withBearerAuth, + accountBillingPortalUrl, + accountBillingSubscriptionUrl, + accountPasswordUrl, + accountReservationSingleUrl, + accountReservationUrl, + accountSettingsUrl, + accountSubscriptionSingleUrl, + accountSubscriptionUrl, + accountTokenUrl, + accountUrl, maybeWithBearerAuth, + tiersUrl, + withBasicAuth, + withBearerAuth } from "./utils"; import session from "./Session"; import subscriptionManager from "./SubscriptionManager"; +import i18n from "i18next"; import prefs from "./Prefs"; import routes from "../components/routes"; -import { fetchOrThrow, UnauthorizedError } from "./errors"; +import {fetchOrThrow, throwAppError, UnauthorizedError} from "./errors"; const delayMillis = 45000; // 45 seconds const intervalMillis = 900000; // 15 minutes class AccountApi { - constructor() { - this.timer = null; - this.listener = null; // Fired when account is fetched from remote - this.tiers = null; // Cached - } - - registerListener(listener) { - this.listener = listener; - } - - resetListener() { - this.listener = null; - } - - async login(user) { - const url = accountTokenUrl(config.base_url); - console.log(`[AccountApi] Checking auth for ${url}`); - const response = await fetchOrThrow(url, { - method: "POST", - headers: withBasicAuth({}, user.username, user.password), - }); - const json = await response.json(); // May throw SyntaxError - if (!json.token) { - throw new Error(`Unexpected server response: Cannot find token`); + constructor() { + this.timer = null; + this.listener = null; // Fired when account is fetched from remote + this.tiers = null; // Cached } - return json.token; - } - async logout() { - const url = accountTokenUrl(config.base_url); - console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`); - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth({}, session.token()), - }); - } - - async create(username, password) { - const url = accountUrl(config.base_url); - const body = JSON.stringify({ - username, - password, - }); - console.log(`[AccountApi] Creating user account ${url}`); - await fetchOrThrow(url, { - method: "POST", - body, - }); - } - - async get() { - const url = accountUrl(config.base_url); - console.log(`[AccountApi] Fetching user account ${url}`); - const response = await fetchOrThrow(url, { - headers: maybeWithBearerAuth({}, session.token()), // GET /v1/account endpoint can be called by anonymous - }); - const account = await response.json(); // May throw SyntaxError - console.log(`[AccountApi] Account`, account); - if (this.listener) { - this.listener(account); + registerListener(listener) { + this.listener = listener; } - return account; - } - async delete(password) { - const url = accountUrl(config.base_url); - console.log(`[AccountApi] Deleting user account ${url}`); - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - password, - }), - }); - } - - async changePassword(currentPassword, newPassword) { - const url = accountPasswordUrl(config.base_url); - console.log(`[AccountApi] Changing account password ${url}`); - await fetchOrThrow(url, { - method: "POST", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - password: currentPassword, - new_password: newPassword, - }), - }); - } - - async createToken(label, expires) { - const url = accountTokenUrl(config.base_url); - const body = { - label, - expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0, - }; - console.log(`[AccountApi] Creating user access token ${url}`); - await fetchOrThrow(url, { - method: "POST", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify(body), - }); - } - - async updateToken(token, label, expires) { - const url = accountTokenUrl(config.base_url); - const body = { - token, - label, - }; - if (expires > 0) { - body.expires = Math.floor(Date.now() / 1000) + expires; + resetListener() { + this.listener = null; } - console.log(`[AccountApi] Creating user access token ${url}`); - await fetchOrThrow(url, { - method: "PATCH", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify(body), - }); - } - async extendToken() { - const url = accountTokenUrl(config.base_url); - console.log(`[AccountApi] Extending user access token ${url}`); - await fetchOrThrow(url, { - method: "PATCH", - headers: withBearerAuth({}, session.token()), - }); - } - - async deleteToken(token) { - const url = accountTokenUrl(config.base_url); - console.log(`[AccountApi] Deleting user access token ${url}`); - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth({ "X-Token": token }, session.token()), - }); - } - - async updateSettings(payload) { - const url = accountSettingsUrl(config.base_url); - const body = JSON.stringify(payload); - console.log(`[AccountApi] Updating user account ${url}: ${body}`); - await fetchOrThrow(url, { - method: "PATCH", - headers: withBearerAuth({}, session.token()), - body, - }); - } - - async addSubscription(baseUrl, topic) { - const url = accountSubscriptionUrl(config.base_url); - const body = JSON.stringify({ - base_url: baseUrl, - topic, - }); - console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); - const response = await fetchOrThrow(url, { - method: "POST", - headers: withBearerAuth({}, session.token()), - body, - }); - const subscription = await response.json(); // May throw SyntaxError - console.log(`[AccountApi] Subscription`, subscription); - return subscription; - } - - async updateSubscription(baseUrl, topic, payload) { - const url = accountSubscriptionUrl(config.base_url); - const body = JSON.stringify({ - base_url: baseUrl, - topic, - ...payload, - }); - console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); - const response = await fetchOrThrow(url, { - method: "PATCH", - headers: withBearerAuth({}, session.token()), - body, - }); - const subscription = await response.json(); // May throw SyntaxError - console.log(`[AccountApi] Subscription`, subscription); - return subscription; - } - - async deleteSubscription(baseUrl, topic) { - const url = accountSubscriptionUrl(config.base_url); - console.log(`[AccountApi] Removing user subscription ${url}`); - const headers = { - "X-BaseURL": baseUrl, - "X-Topic": topic, - }; - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth(headers, session.token()), - }); - } - - async upsertReservation(topic, everyone) { - const url = accountReservationUrl(config.base_url); - console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`); - await fetchOrThrow(url, { - method: "POST", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - topic, - everyone, - }), - }); - } - - async deleteReservation(topic, deleteMessages) { - const url = accountReservationSingleUrl(config.base_url, topic); - console.log(`[AccountApi] Removing topic reservation ${url}`); - const headers = { - "X-Delete-Messages": deleteMessages ? "true" : "false", - }; - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth(headers, session.token()), - }); - } - - async billingTiers() { - if (this.tiers) { - return this.tiers; - } - const url = tiersUrl(config.base_url); - console.log(`[AccountApi] Fetching billing tiers`); - const response = await fetchOrThrow(url); // No auth needed! - this.tiers = await response.json(); // May throw SyntaxError - return this.tiers; - } - - async createBillingSubscription(tier, interval) { - console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`); - return this.upsertBillingSubscription("POST", tier, interval); - } - - async updateBillingSubscription(tier, interval) { - console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`); - return this.upsertBillingSubscription("PUT", tier, interval); - } - - async upsertBillingSubscription(method, tier, interval) { - const url = accountBillingSubscriptionUrl(config.base_url); - const response = await fetchOrThrow(url, { - method, - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - tier, - interval, - }), - }); - return response.json(); // May throw SyntaxError - } - - async deleteBillingSubscription() { - const url = accountBillingSubscriptionUrl(config.base_url); - console.log(`[AccountApi] Cancelling billing subscription`); - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth({}, session.token()), - }); - } - - async createBillingPortalSession() { - const url = accountBillingPortalUrl(config.base_url); - console.log(`[AccountApi] Creating billing portal session`); - const response = await fetchOrThrow(url, { - method: "POST", - headers: withBearerAuth({}, session.token()), - }); - return response.json(); // May throw SyntaxError - } - - async verifyPhoneNumber(phoneNumber, channel) { - const url = accountPhoneVerifyUrl(config.base_url); - console.log(`[AccountApi] Sending phone verification ${url}`); - await fetchOrThrow(url, { - method: "PUT", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - number: phoneNumber, - channel, - }), - }); - } - - async addPhoneNumber(phoneNumber, code) { - const url = accountPhoneUrl(config.base_url); - console.log(`[AccountApi] Adding phone number with verification code ${url}`); - await fetchOrThrow(url, { - method: "PUT", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - number: phoneNumber, - code, - }), - }); - } - - async deletePhoneNumber(phoneNumber) { - const url = accountPhoneUrl(config.base_url); - console.log(`[AccountApi] Deleting phone number ${url}`); - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - number: phoneNumber, - }), - }); - } - - async sync() { - try { - if (!session.token()) { - return null; - } - console.log(`[AccountApi] Syncing account`); - const account = await this.get(); - if (account.language) { - await i18n.changeLanguage(account.language); - } - if (account.notification) { - if (account.notification.sound) { - await prefs.setSound(account.notification.sound); + async login(user) { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Checking auth for ${url}`); + const response = await fetchOrThrow(url, { + method: "POST", + headers: withBasicAuth({}, user.username, user.password) + }); + const json = await response.json(); // May throw SyntaxError + if (!json.token) { + throw new Error(`Unexpected server response: Cannot find token`); } - if (account.notification.delete_after) { - await prefs.setDeleteAfter(account.notification.delete_after); - } - if (account.notification.min_priority) { - await prefs.setMinPriority(account.notification.min_priority); - } - } - if (account.subscriptions) { - await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations); - } - return account; - } catch (e) { - console.log(`[AccountApi] Error fetching account`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - return undefined; + return json.token; } - } - startWorker() { - if (this.timer !== null) { - return; + async logout() { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()) + }); } - console.log(`[AccountApi] Starting worker`); - this.timer = setInterval(() => this.runWorker(), intervalMillis); - setTimeout(() => this.runWorker(), delayMillis); - } - async runWorker() { - if (!session.token()) { - return; + async create(username, password) { + const url = accountUrl(config.base_url); + const body = JSON.stringify({ + username: username, + password: password + }); + console.log(`[AccountApi] Creating user account ${url}`); + await fetchOrThrow(url, { + method: "POST", + body: body + }); } - console.log(`[AccountApi] Extending user access token`); - try { - await this.extendToken(); - } catch (e) { - console.log(`[AccountApi] Error extending user access token`, e); + + async get() { + const url = accountUrl(config.base_url); + console.log(`[AccountApi] Fetching user account ${url}`); + const response = await fetchOrThrow(url, { + headers: maybeWithBearerAuth({}, session.token()) // GET /v1/account endpoint can be called by anonymous + }); + const account = await response.json(); // May throw SyntaxError + console.log(`[AccountApi] Account`, account); + if (this.listener) { + this.listener(account); + } + return account; + } + + async delete(password) { + const url = accountUrl(config.base_url); + console.log(`[AccountApi] Deleting user account ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + password: password + }) + }); + } + + async changePassword(currentPassword, newPassword) { + const url = accountPasswordUrl(config.base_url); + console.log(`[AccountApi] Changing account password ${url}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + password: currentPassword, + new_password: newPassword + }) + }); + } + + async createToken(label, expires) { + const url = accountTokenUrl(config.base_url); + const body = { + label: label, + expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0 + }; + console.log(`[AccountApi] Creating user access token ${url}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify(body) + }); + } + + async updateToken(token, label, expires) { + const url = accountTokenUrl(config.base_url); + const body = { + token: token, + label: label + }; + if (expires > 0) { + body.expires = Math.floor(Date.now() / 1000) + expires; + } + console.log(`[AccountApi] Creating user access token ${url}`); + await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify(body) + }); + } + + async extendToken() { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Extending user access token ${url}`); + await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()) + }); + } + + async deleteToken(token) { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Deleting user access token ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({"X-Token": token}, session.token()) + }); + } + + async updateSettings(payload) { + const url = accountSettingsUrl(config.base_url); + const body = JSON.stringify(payload); + console.log(`[AccountApi] Updating user account ${url}: ${body}`); + await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + body: body + }); + } + + async addSubscription(baseUrl, topic) { + const url = accountSubscriptionUrl(config.base_url); + const body = JSON.stringify({ + base_url: baseUrl, + topic: topic + }); + console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); + const response = await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: body + }); + const subscription = await response.json(); // May throw SyntaxError + console.log(`[AccountApi] Subscription`, subscription); + return subscription; + } + + async updateSubscription(baseUrl, topic, payload) { + const url = accountSubscriptionUrl(config.base_url); + const body = JSON.stringify({ + base_url: baseUrl, + topic: topic, + ...payload + }); + console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); + const response = await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + body: body + }); + const subscription = await response.json(); // May throw SyntaxError + console.log(`[AccountApi] Subscription`, subscription); + return subscription; + } + + async deleteSubscription(baseUrl, topic) { + const url = accountSubscriptionUrl(config.base_url); + console.log(`[AccountApi] Removing user subscription ${url}`); + const headers = { + "X-BaseURL": baseUrl, + "X-Topic": topic, + } + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth(headers, session.token()), + }); + } + + async upsertReservation(topic, everyone) { + const url = accountReservationUrl(config.base_url); + console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + topic: topic, + everyone: everyone + }) + }); + } + + async deleteReservation(topic, deleteMessages) { + const url = accountReservationSingleUrl(config.base_url, topic); + console.log(`[AccountApi] Removing topic reservation ${url}`); + const headers = { + "X-Delete-Messages": deleteMessages ? "true" : "false" + } + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth(headers, session.token()) + }); + } + + async billingTiers() { + if (this.tiers) { + return this.tiers; + } + const url = tiersUrl(config.base_url); + console.log(`[AccountApi] Fetching billing tiers`); + const response = await fetchOrThrow(url); // No auth needed! + this.tiers = await response.json(); // May throw SyntaxError + return this.tiers; + } + + async createBillingSubscription(tier) { + console.log(`[AccountApi] Creating billing subscription with ${tier}`); + return await this.upsertBillingSubscription("POST", tier) + } + + async updateBillingSubscription(tier) { + console.log(`[AccountApi] Updating billing subscription with ${tier}`); + return await this.upsertBillingSubscription("PUT", tier) + } + + async upsertBillingSubscription(method, tier) { + const url = accountBillingSubscriptionUrl(config.base_url); + const response = await fetchOrThrow(url, { + method: method, + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + tier: tier + }) + }); + return await response.json(); // May throw SyntaxError + } + + async deleteBillingSubscription() { + const url = accountBillingSubscriptionUrl(config.base_url); + console.log(`[AccountApi] Cancelling billing subscription`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()) + }); + } + + async createBillingPortalSession() { + const url = accountBillingPortalUrl(config.base_url); + console.log(`[AccountApi] Creating billing portal session`); + const response = await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()) + }); + return await response.json(); // May throw SyntaxError + } + + async sync() { + try { + if (!session.token()) { + return null; + } + console.log(`[AccountApi] Syncing account`); + const account = await this.get(); + if (account.language) { + await i18n.changeLanguage(account.language); + } + if (account.notification) { + if (account.notification.sound) { + await prefs.setSound(account.notification.sound); + } + if (account.notification.delete_after) { + await prefs.setDeleteAfter(account.notification.delete_after); + } + if (account.notification.min_priority) { + await prefs.setMinPriority(account.notification.min_priority); + } + } + if (account.subscriptions) { + await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations); + } + return account; + } catch (e) { + console.log(`[AccountApi] Error fetching account`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } + } + + startWorker() { + if (this.timer !== null) { + return; + } + console.log(`[AccountApi] Starting worker`); + this.timer = setInterval(() => this.runWorker(), intervalMillis); + setTimeout(() => this.runWorker(), delayMillis); + } + + async runWorker() { + if (!session.token()) { + return; + } + console.log(`[AccountApi] Extending user access token`); + try { + await this.extendToken(); + } catch (e) { + console.log(`[AccountApi] Error extending user access token`, e); + } } - } } // Maps to user.Role in user/types.go export const Role = { - ADMIN: "admin", - USER: "user", + ADMIN: "admin", + USER: "user" }; // Maps to server.visitorLimitBasis in server/visitor.go export const LimitBasis = { - IP: "ip", - TIER: "tier", + IP: "ip", + TIER: "tier" }; // Maps to stripe.SubscriptionStatus export const SubscriptionStatus = { - ACTIVE: "active", - PAST_DUE: "past_due", -}; - -// Maps to stripe.PriceRecurringInterval -export const SubscriptionInterval = { - MONTH: "month", - YEAR: "year", + ACTIVE: "active", + PAST_DUE: "past_due" }; // Maps to user.Permission in user/types.go export const Permission = { - READ_WRITE: "read-write", - READ_ONLY: "read-only", - WRITE_ONLY: "write-only", - DENY_ALL: "deny-all", + READ_WRITE: "read-write", + READ_ONLY: "read-only", + WRITE_ONLY: "write-only", + DENY_ALL: "deny-all" }; const accountApi = new AccountApi(); diff --git a/web/src/app/Api.js b/web/src/app/Api.js index ba1cbe6..3d20d92 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -1,118 +1,115 @@ import { - fetchLinesIterator, - maybeWithAuth, - topicShortUrl, - topicUrl, - topicUrlAuth, - topicUrlJsonPoll, - topicUrlJsonPollWithSince, + fetchLinesIterator, + maybeWithAuth, + topicShortUrl, + topicUrl, + topicUrlAuth, + topicUrlJsonPoll, + topicUrlJsonPollWithSince } from "./utils"; import userManager from "./UserManager"; -import { fetchOrThrow } from "./errors"; +import {fetchOrThrow} from "./errors"; class Api { - async poll(baseUrl, topic, since) { - const user = await userManager.get(baseUrl); - const shortUrl = topicShortUrl(baseUrl, topic); - const url = since ? topicUrlJsonPollWithSince(baseUrl, topic, since) : topicUrlJsonPoll(baseUrl, topic); - const messages = []; - const headers = maybeWithAuth({}, user); - console.log(`[Api] Polling ${url}`); - for await (const line of fetchLinesIterator(url, headers)) { - const message = JSON.parse(line); - if (message.id) { - console.log(`[Api, ${shortUrl}] Received message ${line}`); - messages.push(message); - } - } - return messages; - } - - async publish(baseUrl, topic, message, options) { - const user = await userManager.get(baseUrl); - console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`); - const headers = {}; - const body = { - topic, - message, - ...options, - }; - await fetchOrThrow(baseUrl, { - method: "PUT", - body: JSON.stringify(body), - headers: maybeWithAuth(headers, user), - }); - } - - /** - * Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request. - * Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used. - * - * Firefox XHR bug: - * Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error, - * so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the - * correct headers are clearly set. It's quite the odd behavior. - * - * There is an example, and the bug report here: - * - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755 - * - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345 - */ - publishXHR(url, body, headers, onProgress) { - console.log(`[Api] Publishing message to ${url}`); - const xhr = new XMLHttpRequest(); - const send = new Promise((resolve, reject) => { - xhr.open("PUT", url); - if (body.type) { - xhr.overrideMimeType(body.type); - } - for (const [key, value] of Object.entries(headers)) { - xhr.setRequestHeader(key, value); - } - xhr.upload.addEventListener("progress", onProgress); - xhr.addEventListener("readystatechange", () => { - if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) { - console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response); - resolve(xhr.response); - } else if (xhr.readyState === 4) { - // Firefox bug; see description above! - console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText); - let errorText; - try { - const error = JSON.parse(xhr.responseText); - if (error.code && error.error) { - errorText = `Error ${error.code}: ${error.error}`; - } - } catch (e) { - // Nothing - } - xhr.abort(); - reject(errorText ?? "An error occurred"); + async poll(baseUrl, topic, since) { + const user = await userManager.get(baseUrl); + const shortUrl = topicShortUrl(baseUrl, topic); + const url = (since) + ? topicUrlJsonPollWithSince(baseUrl, topic, since) + : topicUrlJsonPoll(baseUrl, topic); + const messages = []; + const headers = maybeWithAuth({}, user); + console.log(`[Api] Polling ${url}`); + for await (let line of fetchLinesIterator(url, headers)) { + console.log(`[Api, ${shortUrl}] Received message ${line}`); + messages.push(JSON.parse(line)); } - }); - xhr.send(body); - }); - send.abort = () => { - console.log(`[Api] Publish aborted by user`); - xhr.abort(); - }; - return send; - } + return messages; + } - async topicAuth(baseUrl, topic, user) { - const url = topicUrlAuth(baseUrl, topic); - console.log(`[Api] Checking auth for ${url}`); - const response = await fetch(url, { - headers: maybeWithAuth({}, user), - }); - if (response.status >= 200 && response.status <= 299) { - return true; + async publish(baseUrl, topic, message, options) { + const user = await userManager.get(baseUrl); + console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`); + const headers = {}; + const body = { + topic: topic, + message: message, + ...options + }; + await fetchOrThrow(baseUrl, { + method: 'PUT', + body: JSON.stringify(body), + headers: maybeWithAuth(headers, user) + }); } - if (response.status === 401 || response.status === 403) { - // See server/server.go - return false; + + /** + * Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request. + * Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used. + * + * Firefox XHR bug: + * Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error, + * so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the + * correct headers are clearly set. It's quite the odd behavior. + * + * There is an example, and the bug report here: + * - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755 + * - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345 + */ + publishXHR(url, body, headers, onProgress) { + console.log(`[Api] Publishing message to ${url}`); + const xhr = new XMLHttpRequest(); + const send = new Promise(function (resolve, reject) { + xhr.open("PUT", url); + if (body.type) { + xhr.overrideMimeType(body.type); + } + for (const [key, value] of Object.entries(headers)) { + xhr.setRequestHeader(key, value); + } + xhr.upload.addEventListener("progress", onProgress); + xhr.addEventListener('readystatechange', () => { + if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) { + console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response); + resolve(xhr.response); + } else if (xhr.readyState === 4) { + // Firefox bug; see description above! + console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText); + let errorText; + try { + const error = JSON.parse(xhr.responseText); + if (error.code && error.error) { + errorText = `Error ${error.code}: ${error.error}`; + } + } catch (e) { + // Nothing + } + xhr.abort(); + reject(errorText ?? "An error occurred"); + } + }) + xhr.send(body); + }); + send.abort = () => { + console.log(`[Api] Publish aborted by user`); + xhr.abort(); + } + return send; + } + + async topicAuth(baseUrl, topic, user) { + const url = topicUrlAuth(baseUrl, topic); + console.log(`[Api] Checking auth for ${url}`); + const response = await fetch(url, { + headers: maybeWithAuth({}, user) + }); + if (response.status >= 200 && response.status <= 299) { + return true; + } else if (response.status === 401 || response.status === 403) { // See server/server.go + return false; + } + throw new Error(`Unexpected server response ${response.status}`); } - throw new Error(`Unexpected server response ${response.status}`); - } } const api = new Api(); diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 5358cdd..8b79537 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -1,13 +1,6 @@ -/* eslint-disable max-classes-per-file */ -import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils"; +import {basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils"; -const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; - -export class ConnectionState { - static Connected = "connected"; - - static Connecting = "connecting"; -} +const retryBackoffSeconds = [5, 10, 15, 20, 30]; /** * A connection contains a single WebSocket connection for one topic. It handles its connection @@ -16,103 +9,110 @@ export class ConnectionState { * Incoming messages and state changes are forwarded via listeners. */ class Connection { - constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) { - this.connectionId = connectionId; - this.subscriptionId = subscriptionId; - this.baseUrl = baseUrl; - this.topic = topic; - this.user = user; - this.since = since; - this.shortUrl = topicShortUrl(baseUrl, topic); - this.onNotification = onNotification; - this.onStateChanged = onStateChanged; - this.ws = null; - this.retryCount = 0; - this.retryTimeout = null; - } - - start() { - // Don't fetch old messages; we do that as a poll() when adding a subscription; - // we don't want to re-trigger the main view re-render potentially hundreds of times. - - const wsUrl = this.wsUrl(); - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`); - - this.ws = new WebSocket(wsUrl); - this.ws.onopen = (event) => { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event); - this.retryCount = 0; - this.onStateChanged(this.subscriptionId, ConnectionState.Connected); - }; - this.ws.onmessage = (event) => { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`); - try { - const data = JSON.parse(event.data); - if (data.event === "open") { - return; - } - const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data; - if (!relevantAndValid) { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); - return; - } - this.since = data.id; - this.onNotification(this.subscriptionId, data); - } catch (e) { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`); - } - }; - this.ws.onclose = (event) => { - if (event.wasClean) { - console.log( - `[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}` - ); + constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) { + this.connectionId = connectionId; + this.subscriptionId = subscriptionId; + this.baseUrl = baseUrl; + this.topic = topic; + this.user = user; + this.since = since; + this.shortUrl = topicShortUrl(baseUrl, topic); + this.onNotification = onNotification; + this.onStateChanged = onStateChanged; this.ws = null; - } else { - const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)]; - this.retryCount += 1; - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`); - this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000); - this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); - } - }; - this.ws.onerror = (event) => { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event); - }; - } + this.retryCount = 0; + this.retryTimeout = null; + } - close() { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`); - const socket = this.ws; - const { retryTimeout } = this; - if (socket !== null) { - socket.close(); - } - if (retryTimeout !== null) { - clearTimeout(retryTimeout); - } - this.retryTimeout = null; - this.ws = null; - } + start() { + // Don't fetch old messages; we do that as a poll() when adding a subscription; + // we don't want to re-trigger the main view re-render potentially hundreds of times. - wsUrl() { - const params = []; - if (this.since) { - params.push(`since=${this.since}`); - } - if (this.user) { - params.push(`auth=${this.authParam()}`); - } - const wsUrl = topicUrlWs(this.baseUrl, this.topic); - return params.length === 0 ? wsUrl : `${wsUrl}?${params.join("&")}`; - } + const wsUrl = this.wsUrl(); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`); - authParam() { - if (this.user.password) { - return encodeBase64Url(basicAuth(this.user.username, this.user.password)); + this.ws = new WebSocket(wsUrl); + this.ws.onopen = (event) => { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event); + this.retryCount = 0; + this.onStateChanged(this.subscriptionId, ConnectionState.Connected); + } + this.ws.onmessage = (event) => { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`); + try { + const data = JSON.parse(event.data); + if (data.event === 'open') { + return; + } + const relevantAndValid = + data.event === 'message' && + 'id' in data && + 'time' in data && + 'message' in data; + if (!relevantAndValid) { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); + return; + } + this.since = data.id; + this.onNotification(this.subscriptionId, data); + } catch (e) { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`); + } + }; + this.ws.onclose = (event) => { + if (event.wasClean) { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`); + this.ws = null; + } else { + const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)]; + this.retryCount++; + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`); + this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000); + this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); + } + }; + this.ws.onerror = (event) => { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event); + }; } - return encodeBase64Url(bearerAuth(this.user.token)); - } + + close() { + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`); + const socket = this.ws; + const retryTimeout = this.retryTimeout; + if (socket !== null) { + socket.close(); + } + if (retryTimeout !== null) { + clearTimeout(retryTimeout); + } + this.retryTimeout = null; + this.ws = null; + } + + wsUrl() { + const params = []; + if (this.since) { + params.push(`since=${this.since}`); + } + if (this.user) { + params.push(`auth=${this.authParam()}`); + } + const wsUrl = topicUrlWs(this.baseUrl, this.topic); + return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`; + } + + authParam() { + if (this.user.password) { + return encodeBase64Url(basicAuth(this.user.username, this.user.password)); + } + return encodeBase64Url(bearerAuth(this.user.token)); + } +} + +export class ConnectionState { + static Connected = "connected"; + static Connecting = "connecting"; } export default Connection; diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index 2033cbe..1e805eb 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -1,8 +1,5 @@ import Connection from "./Connection"; -import { hashCode } from "./utils"; - -const makeConnectionId = async (subscription, user) => - user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`); +import {hashCode} from "./utils"; /** * The connection manager keeps track of active connections (WebSocket connections, see Connection). @@ -11,106 +8,109 @@ const makeConnectionId = async (subscription, user) => * as required. This is done pretty much exactly the same way as in the Android app. */ class ConnectionManager { - constructor() { - this.connections = new Map(); // ConnectionId -> Connection (hash, see below) - this.stateListener = null; // Fired when connection state changes - this.messageListener = null; // Fired when new notifications arrive - } - - registerStateListener(listener) { - this.stateListener = listener; - } - - resetStateListener() { - this.stateListener = null; - } - - registerMessageListener(listener) { - this.messageListener = listener; - } - - resetMessageListener() { - this.messageListener = null; - } - - /** - * This function figures out which websocket connections should be running by comparing the - * current state of the world (connections) with the target state (targetIds). - * - * It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify - * connections. If any of them change, the connection is closed/replaced. - */ - async refresh(subscriptions, users) { - if (!subscriptions || !users) { - return; + constructor() { + this.connections = new Map(); // ConnectionId -> Connection (hash, see below) + this.stateListener = null; // Fired when connection state changes + this.messageListener = null; // Fired when new notifications arrive } - console.log(`[ConnectionManager] Refreshing connections`); - const subscriptionsWithUsersAndConnectionId = await Promise.all( - subscriptions.map(async (s) => { - const [user] = users.filter((u) => u.baseUrl === s.baseUrl); - const connectionId = await makeConnectionId(s, user); - return { ...s, user, connectionId }; - }) - ); - const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId); - const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id)); - // Create and add new connections - subscriptionsWithUsersAndConnectionId.forEach((subscription) => { - const subscriptionId = subscription.id; - const { connectionId } = subscription; - const added = !this.connections.get(connectionId); - if (added) { - const { baseUrl, topic, user } = subscription; - const since = subscription.last; - const connection = new Connection( - connectionId, - subscriptionId, - baseUrl, - topic, - user, - since, - (subId, notification) => this.notificationReceived(subId, notification), - (subId, state) => this.stateChanged(subId, state) - ); - this.connections.set(connectionId, connection); - console.log( - `[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${ - user ? user.username : "anonymous" - })` - ); - connection.start(); - } - }); - - // Delete old connections - deletedIds.forEach((id) => { - console.log(`[ConnectionManager] Closing connection ${id}`); - const connection = this.connections.get(id); - this.connections.delete(id); - connection.close(); - }); - } - - stateChanged(subscriptionId, state) { - if (this.stateListener) { - try { - this.stateListener(subscriptionId, state); - } catch (e) { - console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e); - } + registerStateListener(listener) { + this.stateListener = listener; } - } - notificationReceived(subscriptionId, notification) { - if (this.messageListener) { - try { - this.messageListener(subscriptionId, notification); - } catch (e) { - console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e); - } + resetStateListener() { + this.stateListener = null; } - } + + registerMessageListener(listener) { + this.messageListener = listener; + } + + resetMessageListener() { + this.messageListener = null; + } + + /** + * This function figures out which websocket connections should be running by comparing the + * current state of the world (connections) with the target state (targetIds). + * + * It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify + * connections. If any of them change, the connection is closed/replaced. + */ + async refresh(subscriptions, users) { + if (!subscriptions || !users) { + return; + } + console.log(`[ConnectionManager] Refreshing connections`); + const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions + .map(async s => { + const [user] = users.filter(u => u.baseUrl === s.baseUrl); + const connectionId = await makeConnectionId(s, user); + return {...s, user, connectionId}; + })); + const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId); + const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id)); + + // Create and add new connections + subscriptionsWithUsersAndConnectionId.forEach(subscription => { + const subscriptionId = subscription.id; + const connectionId = subscription.connectionId; + const added = !this.connections.get(connectionId) + if (added) { + const baseUrl = subscription.baseUrl; + const topic = subscription.topic; + const user = subscription.user; + const since = subscription.last; + const connection = new Connection( + connectionId, + subscriptionId, + baseUrl, + topic, + user, + since, + (subscriptionId, notification) => this.notificationReceived(subscriptionId, notification), + (subscriptionId, state) => this.stateChanged(subscriptionId, state) + ); + this.connections.set(connectionId, connection); + console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`); + connection.start(); + } + }); + + // Delete old connections + deletedIds.forEach(id => { + console.log(`[ConnectionManager] Closing connection ${id}`); + const connection = this.connections.get(id); + this.connections.delete(id); + connection.close(); + }); + } + + stateChanged(subscriptionId, state) { + if (this.stateListener) { + try { + this.stateListener(subscriptionId, state); + } catch (e) { + console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e); + } + } + } + + notificationReceived(subscriptionId, notification) { + if (this.messageListener) { + try { + this.messageListener(subscriptionId, notification); + } catch (e) { + console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e); + } + } + } +} + +const makeConnectionId = async (subscription, user) => { + return (user) + ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) + : hashCode(`${subscription.id}`); } const connectionManager = new ConnectionManager(); diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 45792dc..613340c 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -1,4 +1,4 @@ -import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl } from "./utils"; +import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl} from "./utils"; import prefs from "./Prefs"; import subscriptionManager from "./SubscriptionManager"; import logo from "../img/ntfy.png"; @@ -8,87 +8,89 @@ import logo from "../img/ntfy.png"; * support this; most importantly, all iOS browsers do not support window.Notification. */ class Notifier { - async notify(subscriptionId, notification, onClickFallback) { - if (!this.supported()) { - return; - } - const subscription = await subscriptionManager.get(subscriptionId); - const shouldNotify = await this.shouldNotify(subscription, notification); - if (!shouldNotify) { - return; - } - const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); - const displayName = topicDisplayName(subscription); - const message = formatMessage(notification); - const title = formatTitleWithDefault(notification, displayName); + async notify(subscriptionId, notification, onClickFallback) { + if (!this.supported()) { + return; + } + const subscription = await subscriptionManager.get(subscriptionId); + const shouldNotify = await this.shouldNotify(subscription, notification); + if (!shouldNotify) { + return; + } + const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); + const displayName = topicDisplayName(subscription); + const message = formatMessage(notification); + const title = formatTitleWithDefault(notification, displayName); - // Show notification - console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`); - const n = new Notification(title, { - body: message, - icon: logo, - }); - if (notification.click) { - n.onclick = () => openUrl(notification.click); - } else { - n.onclick = () => onClickFallback(subscription); + // Show notification + console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`); + const n = new Notification(title, { + body: message, + icon: logo + }); + if (notification.click) { + n.onclick = (e) => openUrl(notification.click); + } else { + n.onclick = () => onClickFallback(subscription); + } + + // Play sound + const sound = await prefs.sound(); + if (sound && sound !== "none") { + try { + await playSound(sound); + } catch (e) { + console.log(`[Notifier, ${shortUrl}] Error playing audio`, e); + } + } } - // Play sound - const sound = await prefs.sound(); - if (sound && sound !== "none") { - try { - await playSound(sound); - } catch (e) { - console.log(`[Notifier, ${shortUrl}] Error playing audio`, e); - } + granted() { + return this.supported() && Notification.permission === 'granted'; } - } - granted() { - return this.supported() && Notification.permission === "granted"; - } - - maybeRequestPermission(cb) { - if (!this.supported()) { - cb(false); - return; + maybeRequestPermission(cb) { + if (!this.supported()) { + cb(false); + return; + } + if (!this.granted()) { + Notification.requestPermission().then((permission) => { + const granted = permission === 'granted'; + cb(granted); + }); + } } - if (!this.granted()) { - Notification.requestPermission().then((permission) => { - const granted = permission === "granted"; - cb(granted); - }); + + async shouldNotify(subscription, notification) { + if (subscription.mutedUntil === 1) { + return false; + } + const priority = (notification.priority) ? notification.priority : 3; + const minPriority = await prefs.minPriority(); + if (priority < minPriority) { + return false; + } + return true; } - } - async shouldNotify(subscription, notification) { - if (subscription.mutedUntil === 1) { - return false; + supported() { + return this.browserSupported() && this.contextSupported(); } - const priority = notification.priority ? notification.priority : 3; - const minPriority = await prefs.minPriority(); - if (priority < minPriority) { - return false; + + browserSupported() { + return 'Notification' in window; } - return true; - } - supported() { - return this.browserSupported() && this.contextSupported(); - } - - browserSupported() { - return "Notification" in window; - } - - /** - * Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API - * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification - */ - contextSupported() { - return window.location.protocol === "https:" || window.location.hostname.match("^127.") || window.location.hostname === "localhost"; - } + /** + * Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API + * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification + */ + contextSupported() { + return location.protocol === 'https:' + || location.hostname.match('^127.') + || location.hostname === 'localhost'; + } } const notifier = new Notifier(); diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index 372e46e..a7eed03 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -5,57 +5,54 @@ const delayMillis = 2000; // 2 seconds const intervalMillis = 300000; // 5 minutes class Poller { - constructor() { - this.timer = null; - } - - startWorker() { - if (this.timer !== null) { - return; + constructor() { + this.timer = null; } - console.log(`[Poller] Starting worker`); - this.timer = setInterval(() => this.pollAll(), intervalMillis); - setTimeout(() => this.pollAll(), delayMillis); - } - async pollAll() { - console.log(`[Poller] Polling all subscriptions`); - const subscriptions = await subscriptionManager.all(); - - await Promise.all( - subscriptions.map(async (s) => { - try { - await this.poll(s); - } catch (e) { - console.log(`[Poller] Error polling ${s.id}`, e); + startWorker() { + if (this.timer !== null) { + return; } - }) - ); - } - - async poll(subscription) { - console.log(`[Poller] Polling ${subscription.id}`); - - const since = subscription.last; - const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); - if (!notifications || notifications.length === 0) { - console.log(`[Poller] No new notifications found for ${subscription.id}`); - return; + console.log(`[Poller] Starting worker`); + this.timer = setInterval(() => this.pollAll(), intervalMillis); + setTimeout(() => this.pollAll(), delayMillis); } - console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); - await subscriptionManager.addNotifications(subscription.id, notifications); - } - pollInBackground(subscription) { - const fn = async () => { - try { - await this.poll(subscription); - } catch (e) { - console.error(`[App] Error polling subscription ${subscription.id}`, e); - } - }; - setTimeout(() => fn(), 0); - } + async pollAll() { + console.log(`[Poller] Polling all subscriptions`); + const subscriptions = await subscriptionManager.all(); + for (const s of subscriptions) { + try { + await this.poll(s); + } catch (e) { + console.log(`[Poller] Error polling ${s.id}`, e); + } + } + } + + async poll(subscription) { + console.log(`[Poller] Polling ${subscription.id}`); + + const since = subscription.last; + const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); + if (!notifications || notifications.length === 0) { + console.log(`[Poller] No new notifications found for ${subscription.id}`); + return; + } + console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); + await subscriptionManager.addNotifications(subscription.id, notifications); + } + + pollInBackground(subscription) { + const fn = async () => { + try { + await this.poll(subscription); + } catch (e) { + console.error(`[App] Error polling subscription ${subscription.id}`, e); + } + }; + setTimeout(() => fn(), 0); + } } const poller = new Poller(); diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index 8adc508..b444c6f 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -1,32 +1,32 @@ import db from "./db"; class Prefs { - async setSound(sound) { - db.prefs.put({ key: "sound", value: sound.toString() }); - } + async setSound(sound) { + db.prefs.put({key: 'sound', value: sound.toString()}); + } - async sound() { - const sound = await db.prefs.get("sound"); - return sound ? sound.value : "ding"; - } + async sound() { + const sound = await db.prefs.get('sound'); + return (sound) ? sound.value : "ding"; + } - async setMinPriority(minPriority) { - db.prefs.put({ key: "minPriority", value: minPriority.toString() }); - } + async setMinPriority(minPriority) { + db.prefs.put({key: 'minPriority', value: minPriority.toString()}); + } - async minPriority() { - const minPriority = await db.prefs.get("minPriority"); - return minPriority ? Number(minPriority.value) : 1; - } + async minPriority() { + const minPriority = await db.prefs.get('minPriority'); + return (minPriority) ? Number(minPriority.value) : 1; + } - async setDeleteAfter(deleteAfter) { - db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() }); - } + async setDeleteAfter(deleteAfter) { + db.prefs.put({key:'deleteAfter', value: deleteAfter.toString()}); + } - async deleteAfter() { - const deleteAfter = await db.prefs.get("deleteAfter"); - return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week - } + async deleteAfter() { + const deleteAfter = await db.prefs.get('deleteAfter'); + return (deleteAfter) ? Number(deleteAfter.value) : 604800; // Default is one week + } } const prefs = new Prefs(); diff --git a/web/src/app/Pruner.js b/web/src/app/Pruner.js index 498c156..4594805 100644 --- a/web/src/app/Pruner.js +++ b/web/src/app/Pruner.js @@ -5,33 +5,33 @@ const delayMillis = 25000; // 25 seconds const intervalMillis = 1800000; // 30 minutes class Pruner { - constructor() { - this.timer = null; - } + constructor() { + this.timer = null; + } - startWorker() { - if (this.timer !== null) { - return; + startWorker() { + if (this.timer !== null) { + return; + } + console.log(`[Pruner] Starting worker`); + this.timer = setInterval(() => this.prune(), intervalMillis); + setTimeout(() => this.prune(), delayMillis); } - console.log(`[Pruner] Starting worker`); - this.timer = setInterval(() => this.prune(), intervalMillis); - setTimeout(() => this.prune(), delayMillis); - } - async prune() { - const deleteAfterSeconds = await prefs.deleteAfter(); - const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds; - if (deleteAfterSeconds === 0) { - console.log(`[Pruner] Pruning is disabled. Skipping.`); - return; + async prune() { + const deleteAfterSeconds = await prefs.deleteAfter(); + const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds; + if (deleteAfterSeconds === 0) { + console.log(`[Pruner] Pruning is disabled. Skipping.`); + return; + } + console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`); + try { + await subscriptionManager.pruneNotifications(pruneThresholdTimestamp); + } catch (e) { + console.log(`[Pruner] Error pruning old subscriptions`, e); + } } - console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`); - try { - await subscriptionManager.pruneNotifications(pruneThresholdTimestamp); - } catch (e) { - console.log(`[Pruner] Error pruning old subscriptions`, e); - } - } } const pruner = new Pruner(); diff --git a/web/src/app/Session.js b/web/src/app/Session.js index 0b47f93..45f4842 100644 --- a/web/src/app/Session.js +++ b/web/src/app/Session.js @@ -1,30 +1,30 @@ class Session { - store(username, token) { - localStorage.setItem("user", username); - localStorage.setItem("token", token); - } + store(username, token) { + localStorage.setItem("user", username); + localStorage.setItem("token", token); + } - reset() { - localStorage.removeItem("user"); - localStorage.removeItem("token"); - } + reset() { + localStorage.removeItem("user"); + localStorage.removeItem("token"); + } - resetAndRedirect(url) { - this.reset(); - window.location.href = url; - } + resetAndRedirect(url) { + this.reset(); + window.location.href = url; + } - exists() { - return this.username() && this.token(); - } + exists() { + return this.username() && this.token(); + } - username() { - return localStorage.getItem("user"); - } + username() { + return localStorage.getItem("user"); + } - token() { - return localStorage.getItem("token"); - } + token() { + return localStorage.getItem("token"); + } } const session = new Session(); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index ecbe4da..cdfe50e 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -1,189 +1,192 @@ import db from "./db"; -import { topicUrl } from "./utils"; +import {topicUrl} from "./utils"; class SubscriptionManager { - /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */ - async all() { - const subscriptions = await db.subscriptions.toArray(); - return Promise.all( - subscriptions.map(async (s) => ({ - ...s, - new: await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), - })) - ); - } - - async get(subscriptionId) { - return db.subscriptions.get(subscriptionId); - } - - async add(baseUrl, topic, internal) { - const id = topicUrl(baseUrl, topic); - const existingSubscription = await this.get(id); - if (existingSubscription) { - return existingSubscription; + /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */ + async all() { + const subscriptions = await db.subscriptions.toArray(); + await Promise.all(subscriptions.map(async s => { + s.new = await db.notifications + .where({ subscriptionId: s.id, new: 1 }) + .count(); + })); + return subscriptions; } - const subscription = { - id: topicUrl(baseUrl, topic), - baseUrl, - topic, - mutedUntil: 0, - last: null, - internal: internal || false, - }; - await db.subscriptions.put(subscription); - return subscription; - } - async syncFromRemote(remoteSubscriptions, remoteReservations) { - console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); + async get(subscriptionId) { + return await db.subscriptions.get(subscriptionId) + } - // Add remote subscriptions - const remoteIds = await Promise.all( - remoteSubscriptions.map(async (remote) => { - const local = await this.add(remote.base_url, remote.topic, false); - const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null; - - await this.update(local.id, { - displayName: remote.display_name, // May be undefined - reservation, // May be null! - }); - - return local.id; - }) - ); - - // Remove local subscriptions that do not exist remotely - const localSubscriptions = await db.subscriptions.toArray(); - - await Promise.all( - localSubscriptions.map(async (local) => { - const remoteExists = remoteIds.includes(local.id); - if (!local.internal && !remoteExists) { - await this.remove(local.id); + async add(baseUrl, topic, internal) { + const id = topicUrl(baseUrl, topic); + const existingSubscription = await this.get(id); + if (existingSubscription) { + return existingSubscription; } - }) - ); - } - - async updateState(subscriptionId, state) { - db.subscriptions.update(subscriptionId, { state }); - } - - async remove(subscriptionId) { - await db.subscriptions.delete(subscriptionId); - await db.notifications.where({ subscriptionId }).delete(); - } - - async first() { - return db.subscriptions.toCollection().first(); // May be undefined - } - - async getNotifications(subscriptionId) { - // This is quite awkward, but it is the recommended approach as per the Dexie docs. - // It's actually fine, because the reading and filtering is quite fast. The rendering is what's - // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach - - return db.notifications - .orderBy("time") // Sort by time first - .filter((n) => n.subscriptionId === subscriptionId) - .reverse() - .toArray(); - } - - async getAllNotifications() { - return db.notifications - .orderBy("time") // Efficient, see docs - .reverse() - .toArray(); - } - - /** Adds notification, or returns false if it already exists */ - async addNotification(subscriptionId, notification) { - const exists = await db.notifications.get(notification.id); - if (exists) { - return false; + const subscription = { + id: topicUrl(baseUrl, topic), + baseUrl: baseUrl, + topic: topic, + mutedUntil: 0, + last: null, + internal: internal || false + }; + await db.subscriptions.put(subscription); + return subscription; } - try { - await db.notifications.add({ - ...notification, - subscriptionId, - // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation - new: 1, - }); // FIXME consider put() for double tab - await db.subscriptions.update(subscriptionId, { - last: notification.id, - }); - } catch (e) { - console.error(`[SubscriptionManager] Error adding notification`, e); + + async syncFromRemote(remoteSubscriptions, remoteReservations) { + console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); + + // Add remote subscriptions + let remoteIds = []; // = topicUrl(baseUrl, topic) + for (let i = 0; i < remoteSubscriptions.length; i++) { + const remote = remoteSubscriptions[i]; + const local = await this.add(remote.base_url, remote.topic, false); + const reservation = remoteReservations?.find(r => remote.base_url === config.base_url && remote.topic === r.topic) || null; + await this.update(local.id, { + displayName: remote.display_name, // May be undefined + reservation: reservation // May be null! + }); + remoteIds.push(local.id); + } + + // Remove local subscriptions that do not exist remotely + const localSubscriptions = await db.subscriptions.toArray(); + for (let i = 0; i < localSubscriptions.length; i++) { + const local = localSubscriptions[i]; + const remoteExists = remoteIds.includes(local.id); + if (!local.internal && !remoteExists) { + await this.remove(local.id); + } + } } - return true; - } - /** Adds/replaces notifications, will not throw if they exist */ - async addNotifications(subscriptionId, notifications) { - const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId })); - const lastNotificationId = notifications.at(-1).id; - await db.notifications.bulkPut(notificationsWithSubscriptionId); - await db.subscriptions.update(subscriptionId, { - last: lastNotificationId, - }); - } - - async updateNotification(notification) { - const exists = await db.notifications.get(notification.id); - if (!exists) { - return false; + async updateState(subscriptionId, state) { + db.subscriptions.update(subscriptionId, { state: state }); } - try { - await db.notifications.put({ ...notification }); - } catch (e) { - console.error(`[SubscriptionManager] Error updating notification`, e); + + async remove(subscriptionId) { + await db.subscriptions.delete(subscriptionId); + await db.notifications + .where({subscriptionId: subscriptionId}) + .delete(); } - return true; - } - async deleteNotification(notificationId) { - await db.notifications.delete(notificationId); - } + async first() { + return db.subscriptions.toCollection().first(); // May be undefined + } - async deleteNotifications(subscriptionId) { - await db.notifications.where({ subscriptionId }).delete(); - } + async getNotifications(subscriptionId) { + // This is quite awkward, but it is the recommended approach as per the Dexie docs. + // It's actually fine, because the reading and filtering is quite fast. The rendering is what's + // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach - async markNotificationRead(notificationId) { - await db.notifications.where({ id: notificationId }).modify({ new: 0 }); - } + return db.notifications + .orderBy("time") // Sort by time first + .filter(n => n.subscriptionId === subscriptionId) + .reverse() + .toArray(); + } - async markNotificationsRead(subscriptionId) { - await db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 }); - } + async getAllNotifications() { + return db.notifications + .orderBy("time") // Efficient, see docs + .reverse() + .toArray(); + } - async setMutedUntil(subscriptionId, mutedUntil) { - await db.subscriptions.update(subscriptionId, { - mutedUntil, - }); - } + /** Adds notification, or returns false if it already exists */ + async addNotification(subscriptionId, notification) { + const exists = await db.notifications.get(notification.id); + if (exists) { + return false; + } + try { + notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab + await db.subscriptions.update(subscriptionId, { + last: notification.id + }); + } catch (e) { + console.error(`[SubscriptionManager] Error adding notification`, e); + } + return true; + } - async setDisplayName(subscriptionId, displayName) { - await db.subscriptions.update(subscriptionId, { - displayName, - }); - } + /** Adds/replaces notifications, will not throw if they exist */ + async addNotifications(subscriptionId, notifications) { + const notificationsWithSubscriptionId = notifications + .map(notification => ({ ...notification, subscriptionId })); + const lastNotificationId = notifications.at(-1).id; + await db.notifications.bulkPut(notificationsWithSubscriptionId); + await db.subscriptions.update(subscriptionId, { + last: lastNotificationId + }); + } - async setReservation(subscriptionId, reservation) { - await db.subscriptions.update(subscriptionId, { - reservation, - }); - } + async updateNotification(notification) { + const exists = await db.notifications.get(notification.id); + if (!exists) { + return false; + } + try { + await db.notifications.put({ ...notification }); + } catch (e) { + console.error(`[SubscriptionManager] Error updating notification`, e); + } + return true; + } - async update(subscriptionId, params) { - await db.subscriptions.update(subscriptionId, params); - } + async deleteNotification(notificationId) { + await db.notifications.delete(notificationId); + } - async pruneNotifications(thresholdTimestamp) { - await db.notifications.where("time").below(thresholdTimestamp).delete(); - } + async deleteNotifications(subscriptionId) { + await db.notifications + .where({subscriptionId: subscriptionId}) + .delete(); + } + + async markNotificationRead(notificationId) { + await db.notifications + .where({id: notificationId}) + .modify({new: 0}); + } + + async markNotificationsRead(subscriptionId) { + await db.notifications + .where({subscriptionId: subscriptionId, new: 1}) + .modify({new: 0}); + } + + async setMutedUntil(subscriptionId, mutedUntil) { + await db.subscriptions.update(subscriptionId, { + mutedUntil: mutedUntil + }); + } + + async setDisplayName(subscriptionId, displayName) { + await db.subscriptions.update(subscriptionId, { + displayName: displayName + }); + } + + async setReservation(subscriptionId, reservation) { + await db.subscriptions.update(subscriptionId, { + reservation: reservation + }); + } + + async update(subscriptionId, params) { + await db.subscriptions.update(subscriptionId, params); + } + + async pruneNotifications(thresholdTimestamp) { + await db.notifications + .where("time").below(thresholdTimestamp) + .delete(); + } } const subscriptionManager = new SubscriptionManager(); diff --git a/web/src/app/UserManager.js b/web/src/app/UserManager.js index 2cdd544..1e54eb0 100644 --- a/web/src/app/UserManager.js +++ b/web/src/app/UserManager.js @@ -2,45 +2,45 @@ import db from "./db"; import session from "./Session"; class UserManager { - async all() { - const users = await db.users.toArray(); - if (session.exists()) { - users.unshift(this.localUser()); + async all() { + const users = await db.users.toArray(); + if (session.exists()) { + users.unshift(this.localUser()); + } + return users; } - return users; - } - async get(baseUrl) { - if (session.exists() && baseUrl === config.base_url) { - return this.localUser(); + async get(baseUrl) { + if (session.exists() && baseUrl === config.base_url) { + return this.localUser(); + } + return db.users.get(baseUrl); } - return db.users.get(baseUrl); - } - async save(user) { - if (session.exists() && user.baseUrl === config.base_url) { - return; + async save(user) { + if (session.exists() && user.baseUrl === config.base_url) { + return; + } + await db.users.put(user); } - await db.users.put(user); - } - async delete(baseUrl) { - if (session.exists() && baseUrl === config.base_url) { - return; + async delete(baseUrl) { + if (session.exists() && baseUrl === config.base_url) { + return; + } + await db.users.delete(baseUrl); } - await db.users.delete(baseUrl); - } - localUser() { - if (!session.exists()) { - return null; + localUser() { + if (!session.exists()) { + return null; + } + return { + baseUrl: config.base_url, + username: session.username(), + token: session.token() // Not "password"! + }; } - return { - baseUrl: config.base_url, - username: session.username(), - token: session.token(), // Not "password"! - }; - } } const userManager = new UserManager(); diff --git a/web/src/app/config.js b/web/src/app/config.js index 24e86f3..bdec53e 100644 --- a/web/src/app/config.js +++ b/web/src/app/config.js @@ -1,9 +1,9 @@ -const { config } = window; +const config = window.config; // The backend returns an empty base_url for the config struct, // so the frontend (hey, that's us!) can use the current location. if (!config.base_url || config.base_url === "") { - config.base_url = window.location.origin; + config.base_url = window.location.origin; } export default config; diff --git a/web/src/app/db.js b/web/src/app/db.js index 0e1a5e7..564ee1c 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -1,4 +1,4 @@ -import Dexie from "dexie"; +import Dexie from 'dexie'; import session from "./Session"; // Uses Dexie.js @@ -8,14 +8,14 @@ import session from "./Session"; // - As per docs, we only declare the indexable columns, not all columns // The IndexedDB database name is based on the logged-in user -const dbName = session.username() ? `ntfy-${session.username()}` : "ntfy"; +const dbName = (session.username()) ? `ntfy-${session.username()}` : "ntfy"; const db = new Dexie(dbName); db.version(1).stores({ - subscriptions: "&id,baseUrl", - notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance - users: "&baseUrl,username", - prefs: "&key", + subscriptions: '&id,baseUrl', + notifications: '&id,subscriptionId,time,new,[subscriptionId+new]', // compound key for query performance + users: '&baseUrl,username', + prefs: '&key' }); export default db; diff --git a/web/src/app/emojis.js b/web/src/app/emojis.js index b7912c3..f6dac7b 100644 --- a/web/src/app/emojis.js +++ b/web/src/app/emojis.js @@ -1,14500 +1,3 @@ // This file is generated by scripts/emoji-convert.sh to reduce the size // Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json -export const rawEmojis = [ - { - emoji: "😀", - aliases: ["grinning"], - tags: ["smile", "happy"], - category: "Smileys & Emotion", - description: "grinning face", - unicode_version: "6.1", - }, - { - emoji: "😃", - aliases: ["smiley"], - tags: ["happy", "joy", "haha"], - category: "Smileys & Emotion", - description: "grinning face with big eyes", - unicode_version: "6.0", - }, - { - emoji: "😄", - aliases: ["smile"], - tags: ["happy", "joy", "laugh", "pleased"], - category: "Smileys & Emotion", - description: "grinning face with smiling eyes", - unicode_version: "6.0", - }, - { - emoji: "😁", - aliases: ["grin"], - tags: [], - category: "Smileys & Emotion", - description: "beaming face with smiling eyes", - unicode_version: "6.0", - }, - { - emoji: "😆", - aliases: ["laughing", "satisfied"], - tags: ["happy", "haha"], - category: "Smileys & Emotion", - description: "grinning squinting face", - unicode_version: "6.0", - }, - { - emoji: "😅", - aliases: ["sweat_smile"], - tags: ["hot"], - category: "Smileys & Emotion", - description: "grinning face with sweat", - unicode_version: "6.0", - }, - { - emoji: "🤣", - aliases: ["rofl"], - tags: ["lol", "laughing"], - category: "Smileys & Emotion", - description: "rolling on the floor laughing", - unicode_version: "9.0", - }, - { - emoji: "😂", - aliases: ["joy"], - tags: ["tears"], - category: "Smileys & Emotion", - description: "face with tears of joy", - unicode_version: "6.0", - }, - { - emoji: "🙂", - aliases: ["slightly_smiling_face"], - tags: [], - category: "Smileys & Emotion", - description: "slightly smiling face", - unicode_version: "7.0", - }, - { - emoji: "🙃", - aliases: ["upside_down_face"], - tags: [], - category: "Smileys & Emotion", - description: "upside-down face", - unicode_version: "8.0", - }, - { - emoji: "😉", - aliases: ["wink"], - tags: ["flirt"], - category: "Smileys & Emotion", - description: "winking face", - unicode_version: "6.0", - }, - { - emoji: "😊", - aliases: ["blush"], - tags: ["proud"], - category: "Smileys & Emotion", - description: "smiling face with smiling eyes", - unicode_version: "6.0", - }, - { - emoji: "😇", - aliases: ["innocent"], - tags: ["angel"], - category: "Smileys & Emotion", - description: "smiling face with halo", - unicode_version: "6.0", - }, - { - emoji: "🥰", - aliases: ["smiling_face_with_three_hearts"], - tags: ["love"], - category: "Smileys & Emotion", - description: "smiling face with hearts", - unicode_version: "11.0", - }, - { - emoji: "😍", - aliases: ["heart_eyes"], - tags: ["love", "crush"], - category: "Smileys & Emotion", - description: "smiling face with heart-eyes", - unicode_version: "6.0", - }, - { - emoji: "🤩", - aliases: ["star_struck"], - tags: ["eyes"], - category: "Smileys & Emotion", - description: "star-struck", - unicode_version: "11.0", - }, - { - emoji: "😘", - aliases: ["kissing_heart"], - tags: ["flirt"], - category: "Smileys & Emotion", - description: "face blowing a kiss", - unicode_version: "6.0", - }, - { - emoji: "😗", - aliases: ["kissing"], - tags: [], - category: "Smileys & Emotion", - description: "kissing face", - unicode_version: "6.1", - }, - { - emoji: "☺️", - aliases: ["relaxed"], - tags: ["blush", "pleased"], - category: "Smileys & Emotion", - description: "smiling face", - unicode_version: "", - }, - { - emoji: "😚", - aliases: ["kissing_closed_eyes"], - tags: [], - category: "Smileys & Emotion", - description: "kissing face with closed eyes", - unicode_version: "6.0", - }, - { - emoji: "😙", - aliases: ["kissing_smiling_eyes"], - tags: [], - category: "Smileys & Emotion", - description: "kissing face with smiling eyes", - unicode_version: "6.1", - }, - { - emoji: "🥲", - aliases: ["smiling_face_with_tear"], - tags: [], - category: "Smileys & Emotion", - description: "smiling face with tear", - unicode_version: "13.0", - }, - { - emoji: "😋", - aliases: ["yum"], - tags: ["tongue", "lick"], - category: "Smileys & Emotion", - description: "face savoring food", - unicode_version: "6.0", - }, - { - emoji: "😛", - aliases: ["stuck_out_tongue"], - tags: [], - category: "Smileys & Emotion", - description: "face with tongue", - unicode_version: "6.1", - }, - { - emoji: "😜", - aliases: ["stuck_out_tongue_winking_eye"], - tags: ["prank", "silly"], - category: "Smileys & Emotion", - description: "winking face with tongue", - unicode_version: "6.0", - }, - { - emoji: "🤪", - aliases: ["zany_face"], - tags: ["goofy", "wacky"], - category: "Smileys & Emotion", - description: "zany face", - unicode_version: "11.0", - }, - { - emoji: "😝", - aliases: ["stuck_out_tongue_closed_eyes"], - tags: ["prank"], - category: "Smileys & Emotion", - description: "squinting face with tongue", - unicode_version: "6.0", - }, - { - emoji: "🤑", - aliases: ["money_mouth_face"], - tags: ["rich"], - category: "Smileys & Emotion", - description: "money-mouth face", - unicode_version: "8.0", - }, - { - emoji: "🤗", - aliases: ["hugs"], - tags: [], - category: "Smileys & Emotion", - description: "hugging face", - unicode_version: "8.0", - }, - { - emoji: "🤭", - aliases: ["hand_over_mouth"], - tags: ["quiet", "whoops"], - category: "Smileys & Emotion", - description: "face with hand over mouth", - unicode_version: "11.0", - }, - { - emoji: "🤫", - aliases: ["shushing_face"], - tags: ["silence", "quiet"], - category: "Smileys & Emotion", - description: "shushing face", - unicode_version: "11.0", - }, - { - emoji: "🤔", - aliases: ["thinking"], - tags: [], - category: "Smileys & Emotion", - description: "thinking face", - unicode_version: "8.0", - }, - { - emoji: "🤐", - aliases: ["zipper_mouth_face"], - tags: ["silence", "hush"], - category: "Smileys & Emotion", - description: "zipper-mouth face", - unicode_version: "8.0", - }, - { - emoji: "🤨", - aliases: ["raised_eyebrow"], - tags: ["suspicious"], - category: "Smileys & Emotion", - description: "face with raised eyebrow", - unicode_version: "11.0", - }, - { - emoji: "😐", - aliases: ["neutral_face"], - tags: ["meh"], - category: "Smileys & Emotion", - description: "neutral face", - unicode_version: "6.0", - }, - { - emoji: "😑", - aliases: ["expressionless"], - tags: [], - category: "Smileys & Emotion", - description: "expressionless face", - unicode_version: "6.1", - }, - { - emoji: "😶", - aliases: ["no_mouth"], - tags: ["mute", "silence"], - category: "Smileys & Emotion", - description: "face without mouth", - unicode_version: "6.0", - }, - { - emoji: "😶‍🌫️", - aliases: ["face_in_clouds"], - tags: [], - category: "Smileys & Emotion", - description: "face in clouds", - unicode_version: "13.1", - }, - { - emoji: "😏", - aliases: ["smirk"], - tags: ["smug"], - category: "Smileys & Emotion", - description: "smirking face", - unicode_version: "6.0", - }, - { - emoji: "😒", - aliases: ["unamused"], - tags: ["meh"], - category: "Smileys & Emotion", - description: "unamused face", - unicode_version: "6.0", - }, - { - emoji: "🙄", - aliases: ["roll_eyes"], - tags: [], - category: "Smileys & Emotion", - description: "face with rolling eyes", - unicode_version: "8.0", - }, - { - emoji: "😬", - aliases: ["grimacing"], - tags: [], - category: "Smileys & Emotion", - description: "grimacing face", - unicode_version: "6.1", - }, - { - emoji: "😮‍💨", - aliases: ["face_exhaling"], - tags: [], - category: "Smileys & Emotion", - description: "face exhaling", - unicode_version: "13.1", - }, - { - emoji: "🤥", - aliases: ["lying_face"], - tags: ["liar"], - category: "Smileys & Emotion", - description: "lying face", - unicode_version: "9.0", - }, - { - emoji: "😌", - aliases: ["relieved"], - tags: ["whew"], - category: "Smileys & Emotion", - description: "relieved face", - unicode_version: "6.0", - }, - { - emoji: "😔", - aliases: ["pensive"], - tags: [], - category: "Smileys & Emotion", - description: "pensive face", - unicode_version: "6.0", - }, - { - emoji: "😪", - aliases: ["sleepy"], - tags: ["tired"], - category: "Smileys & Emotion", - description: "sleepy face", - unicode_version: "6.0", - }, - { - emoji: "🤤", - aliases: ["drooling_face"], - tags: [], - category: "Smileys & Emotion", - description: "drooling face", - unicode_version: "9.0", - }, - { - emoji: "😴", - aliases: ["sleeping"], - tags: ["zzz"], - category: "Smileys & Emotion", - description: "sleeping face", - unicode_version: "6.1", - }, - { - emoji: "😷", - aliases: ["mask"], - tags: ["sick", "ill"], - category: "Smileys & Emotion", - description: "face with medical mask", - unicode_version: "6.0", - }, - { - emoji: "🤒", - aliases: ["face_with_thermometer"], - tags: ["sick"], - category: "Smileys & Emotion", - description: "face with thermometer", - unicode_version: "8.0", - }, - { - emoji: "🤕", - aliases: ["face_with_head_bandage"], - tags: ["hurt"], - category: "Smileys & Emotion", - description: "face with head-bandage", - unicode_version: "8.0", - }, - { - emoji: "🤢", - aliases: ["nauseated_face"], - tags: ["sick", "barf", "disgusted"], - category: "Smileys & Emotion", - description: "nauseated face", - unicode_version: "9.0", - }, - { - emoji: "🤮", - aliases: ["vomiting_face"], - tags: ["barf", "sick"], - category: "Smileys & Emotion", - description: "face vomiting", - unicode_version: "11.0", - }, - { - emoji: "🤧", - aliases: ["sneezing_face"], - tags: ["achoo", "sick"], - category: "Smileys & Emotion", - description: "sneezing face", - unicode_version: "9.0", - }, - { - emoji: "🥵", - aliases: ["hot_face"], - tags: ["heat", "sweating"], - category: "Smileys & Emotion", - description: "hot face", - unicode_version: "11.0", - }, - { - emoji: "🥶", - aliases: ["cold_face"], - tags: ["freezing", "ice"], - category: "Smileys & Emotion", - description: "cold face", - unicode_version: "11.0", - }, - { - emoji: "🥴", - aliases: ["woozy_face"], - tags: ["groggy"], - category: "Smileys & Emotion", - description: "woozy face", - unicode_version: "11.0", - }, - { - emoji: "😵", - aliases: ["dizzy_face"], - tags: [], - category: "Smileys & Emotion", - description: "knocked-out face", - unicode_version: "6.0", - }, - { - emoji: "😵‍💫", - aliases: ["face_with_spiral_eyes"], - tags: [], - category: "Smileys & Emotion", - description: "face with spiral eyes", - unicode_version: "13.1", - }, - { - emoji: "🤯", - aliases: ["exploding_head"], - tags: ["mind", "blown"], - category: "Smileys & Emotion", - description: "exploding head", - unicode_version: "11.0", - }, - { - emoji: "🤠", - aliases: ["cowboy_hat_face"], - tags: [], - category: "Smileys & Emotion", - description: "cowboy hat face", - unicode_version: "9.0", - }, - { - emoji: "🥳", - aliases: ["partying_face"], - tags: ["celebration", "birthday"], - category: "Smileys & Emotion", - description: "partying face", - unicode_version: "11.0", - }, - { - emoji: "🥸", - aliases: ["disguised_face"], - tags: [], - category: "Smileys & Emotion", - description: "disguised face", - unicode_version: "13.0", - }, - { - emoji: "😎", - aliases: ["sunglasses"], - tags: ["cool"], - category: "Smileys & Emotion", - description: "smiling face with sunglasses", - unicode_version: "6.0", - }, - { - emoji: "🤓", - aliases: ["nerd_face"], - tags: ["geek", "glasses"], - category: "Smileys & Emotion", - description: "nerd face", - unicode_version: "8.0", - }, - { - emoji: "🧐", - aliases: ["monocle_face"], - tags: [], - category: "Smileys & Emotion", - description: "face with monocle", - unicode_version: "11.0", - }, - { - emoji: "😕", - aliases: ["confused"], - tags: [], - category: "Smileys & Emotion", - description: "confused face", - unicode_version: "6.1", - }, - { - emoji: "😟", - aliases: ["worried"], - tags: ["nervous"], - category: "Smileys & Emotion", - description: "worried face", - unicode_version: "6.1", - }, - { - emoji: "🙁", - aliases: ["slightly_frowning_face"], - tags: [], - category: "Smileys & Emotion", - description: "slightly frowning face", - unicode_version: "7.0", - }, - { - emoji: "☹️", - aliases: ["frowning_face"], - tags: [], - category: "Smileys & Emotion", - description: "frowning face", - unicode_version: "", - }, - { - emoji: "😮", - aliases: ["open_mouth"], - tags: ["surprise", "impressed", "wow"], - category: "Smileys & Emotion", - description: "face with open mouth", - unicode_version: "6.1", - }, - { - emoji: "😯", - aliases: ["hushed"], - tags: ["silence", "speechless"], - category: "Smileys & Emotion", - description: "hushed face", - unicode_version: "6.1", - }, - { - emoji: "😲", - aliases: ["astonished"], - tags: ["amazed", "gasp"], - category: "Smileys & Emotion", - description: "astonished face", - unicode_version: "6.0", - }, - { - emoji: "😳", - aliases: ["flushed"], - tags: [], - category: "Smileys & Emotion", - description: "flushed face", - unicode_version: "6.0", - }, - { - emoji: "🥺", - aliases: ["pleading_face"], - tags: ["puppy", "eyes"], - category: "Smileys & Emotion", - description: "pleading face", - unicode_version: "11.0", - }, - { - emoji: "😦", - aliases: ["frowning"], - tags: [], - category: "Smileys & Emotion", - description: "frowning face with open mouth", - unicode_version: "6.1", - }, - { - emoji: "😧", - aliases: ["anguished"], - tags: ["stunned"], - category: "Smileys & Emotion", - description: "anguished face", - unicode_version: "6.1", - }, - { - emoji: "😨", - aliases: ["fearful"], - tags: ["scared", "shocked", "oops"], - category: "Smileys & Emotion", - description: "fearful face", - unicode_version: "6.0", - }, - { - emoji: "😰", - aliases: ["cold_sweat"], - tags: ["nervous"], - category: "Smileys & Emotion", - description: "anxious face with sweat", - unicode_version: "6.0", - }, - { - emoji: "😥", - aliases: ["disappointed_relieved"], - tags: ["phew", "sweat", "nervous"], - category: "Smileys & Emotion", - description: "sad but relieved face", - unicode_version: "6.0", - }, - { - emoji: "😢", - aliases: ["cry"], - tags: ["sad", "tear"], - category: "Smileys & Emotion", - description: "crying face", - unicode_version: "6.0", - }, - { - emoji: "😭", - aliases: ["sob"], - tags: ["sad", "cry", "bawling"], - category: "Smileys & Emotion", - description: "loudly crying face", - unicode_version: "6.0", - }, - { - emoji: "😱", - aliases: ["scream"], - tags: ["horror", "shocked"], - category: "Smileys & Emotion", - description: "face screaming in fear", - unicode_version: "6.0", - }, - { - emoji: "😖", - aliases: ["confounded"], - tags: [], - category: "Smileys & Emotion", - description: "confounded face", - unicode_version: "6.0", - }, - { - emoji: "😣", - aliases: ["persevere"], - tags: ["struggling"], - category: "Smileys & Emotion", - description: "persevering face", - unicode_version: "6.0", - }, - { - emoji: "😞", - aliases: ["disappointed"], - tags: ["sad"], - category: "Smileys & Emotion", - description: "disappointed face", - unicode_version: "6.0", - }, - { - emoji: "😓", - aliases: ["sweat"], - tags: [], - category: "Smileys & Emotion", - description: "downcast face with sweat", - unicode_version: "6.0", - }, - { - emoji: "😩", - aliases: ["weary"], - tags: ["tired"], - category: "Smileys & Emotion", - description: "weary face", - unicode_version: "6.0", - }, - { - emoji: "😫", - aliases: ["tired_face"], - tags: ["upset", "whine"], - category: "Smileys & Emotion", - description: "tired face", - unicode_version: "6.0", - }, - { - emoji: "🥱", - aliases: ["yawning_face"], - tags: [], - category: "Smileys & Emotion", - description: "yawning face", - unicode_version: "12.0", - }, - { - emoji: "😤", - aliases: ["triumph"], - tags: ["smug"], - category: "Smileys & Emotion", - description: "face with steam from nose", - unicode_version: "6.0", - }, - { - emoji: "😡", - aliases: ["rage", "pout"], - tags: ["angry"], - category: "Smileys & Emotion", - description: "pouting face", - unicode_version: "6.0", - }, - { - emoji: "😠", - aliases: ["angry"], - tags: ["mad", "annoyed"], - category: "Smileys & Emotion", - description: "angry face", - unicode_version: "6.0", - }, - { - emoji: "🤬", - aliases: ["cursing_face"], - tags: ["foul"], - category: "Smileys & Emotion", - description: "face with symbols on mouth", - unicode_version: "11.0", - }, - { - emoji: "😈", - aliases: ["smiling_imp"], - tags: ["devil", "evil", "horns"], - category: "Smileys & Emotion", - description: "smiling face with horns", - unicode_version: "6.0", - }, - { - emoji: "👿", - aliases: ["imp"], - tags: ["angry", "devil", "evil", "horns"], - category: "Smileys & Emotion", - description: "angry face with horns", - unicode_version: "6.0", - }, - { - emoji: "💀", - aliases: ["skull"], - tags: ["dead", "danger", "poison"], - category: "Smileys & Emotion", - description: "skull", - unicode_version: "6.0", - }, - { - emoji: "☠️", - aliases: ["skull_and_crossbones"], - tags: ["danger", "pirate"], - category: "Smileys & Emotion", - description: "skull and crossbones", - unicode_version: "", - }, - { - emoji: "💩", - aliases: ["hankey", "poop", "shit"], - tags: ["crap"], - category: "Smileys & Emotion", - description: "pile of poo", - unicode_version: "6.0", - }, - { - emoji: "🤡", - aliases: ["clown_face"], - tags: [], - category: "Smileys & Emotion", - description: "clown face", - unicode_version: "9.0", - }, - { - emoji: "👹", - aliases: ["japanese_ogre"], - tags: ["monster"], - category: "Smileys & Emotion", - description: "ogre", - unicode_version: "6.0", - }, - { - emoji: "👺", - aliases: ["japanese_goblin"], - tags: [], - category: "Smileys & Emotion", - description: "goblin", - unicode_version: "6.0", - }, - { - emoji: "👻", - aliases: ["ghost"], - tags: ["halloween"], - category: "Smileys & Emotion", - description: "ghost", - unicode_version: "6.0", - }, - { - emoji: "👽", - aliases: ["alien"], - tags: ["ufo"], - category: "Smileys & Emotion", - description: "alien", - unicode_version: "6.0", - }, - { - emoji: "👾", - aliases: ["space_invader"], - tags: ["game", "retro"], - category: "Smileys & Emotion", - description: "alien monster", - unicode_version: "6.0", - }, - { - emoji: "🤖", - aliases: ["robot"], - tags: [], - category: "Smileys & Emotion", - description: "robot", - unicode_version: "8.0", - }, - { - emoji: "😺", - aliases: ["smiley_cat"], - tags: [], - category: "Smileys & Emotion", - description: "grinning cat", - unicode_version: "6.0", - }, - { - emoji: "😸", - aliases: ["smile_cat"], - tags: [], - category: "Smileys & Emotion", - description: "grinning cat with smiling eyes", - unicode_version: "6.0", - }, - { - emoji: "😹", - aliases: ["joy_cat"], - tags: [], - category: "Smileys & Emotion", - description: "cat with tears of joy", - unicode_version: "6.0", - }, - { - emoji: "😻", - aliases: ["heart_eyes_cat"], - tags: [], - category: "Smileys & Emotion", - description: "smiling cat with heart-eyes", - unicode_version: "6.0", - }, - { - emoji: "😼", - aliases: ["smirk_cat"], - tags: [], - category: "Smileys & Emotion", - description: "cat with wry smile", - unicode_version: "6.0", - }, - { - emoji: "😽", - aliases: ["kissing_cat"], - tags: [], - category: "Smileys & Emotion", - description: "kissing cat", - unicode_version: "6.0", - }, - { - emoji: "🙀", - aliases: ["scream_cat"], - tags: ["horror"], - category: "Smileys & Emotion", - description: "weary cat", - unicode_version: "6.0", - }, - { - emoji: "😿", - aliases: ["crying_cat_face"], - tags: ["sad", "tear"], - category: "Smileys & Emotion", - description: "crying cat", - unicode_version: "6.0", - }, - { - emoji: "😾", - aliases: ["pouting_cat"], - tags: [], - category: "Smileys & Emotion", - description: "pouting cat", - unicode_version: "6.0", - }, - { - emoji: "🙈", - aliases: ["see_no_evil"], - tags: ["monkey", "blind", "ignore"], - category: "Smileys & Emotion", - description: "see-no-evil monkey", - unicode_version: "6.0", - }, - { - emoji: "🙉", - aliases: ["hear_no_evil"], - tags: ["monkey", "deaf"], - category: "Smileys & Emotion", - description: "hear-no-evil monkey", - unicode_version: "6.0", - }, - { - emoji: "🙊", - aliases: ["speak_no_evil"], - tags: ["monkey", "mute", "hush"], - category: "Smileys & Emotion", - description: "speak-no-evil monkey", - unicode_version: "6.0", - }, - { - emoji: "💋", - aliases: ["kiss"], - tags: ["lipstick"], - category: "Smileys & Emotion", - description: "kiss mark", - unicode_version: "6.0", - }, - { - emoji: "💌", - aliases: ["love_letter"], - tags: ["email", "envelope"], - category: "Smileys & Emotion", - description: "love letter", - unicode_version: "6.0", - }, - { - emoji: "💘", - aliases: ["cupid"], - tags: ["love", "heart"], - category: "Smileys & Emotion", - description: "heart with arrow", - unicode_version: "6.0", - }, - { - emoji: "💝", - aliases: ["gift_heart"], - tags: ["chocolates"], - category: "Smileys & Emotion", - description: "heart with ribbon", - unicode_version: "6.0", - }, - { - emoji: "💖", - aliases: ["sparkling_heart"], - tags: [], - category: "Smileys & Emotion", - description: "sparkling heart", - unicode_version: "6.0", - }, - { - emoji: "💗", - aliases: ["heartpulse"], - tags: [], - category: "Smileys & Emotion", - description: "growing heart", - unicode_version: "6.0", - }, - { - emoji: "💓", - aliases: ["heartbeat"], - tags: [], - category: "Smileys & Emotion", - description: "beating heart", - unicode_version: "6.0", - }, - { - emoji: "💞", - aliases: ["revolving_hearts"], - tags: [], - category: "Smileys & Emotion", - description: "revolving hearts", - unicode_version: "6.0", - }, - { - emoji: "💕", - aliases: ["two_hearts"], - tags: [], - category: "Smileys & Emotion", - description: "two hearts", - unicode_version: "6.0", - }, - { - emoji: "💟", - aliases: ["heart_decoration"], - tags: [], - category: "Smileys & Emotion", - description: "heart decoration", - unicode_version: "6.0", - }, - { - emoji: "❣️", - aliases: ["heavy_heart_exclamation"], - tags: [], - category: "Smileys & Emotion", - description: "heart exclamation", - unicode_version: "", - }, - { - emoji: "💔", - aliases: ["broken_heart"], - tags: [], - category: "Smileys & Emotion", - description: "broken heart", - unicode_version: "6.0", - }, - { - emoji: "❤️‍🔥", - aliases: ["heart_on_fire"], - tags: [], - category: "Smileys & Emotion", - description: "heart on fire", - unicode_version: "13.1", - }, - { - emoji: "❤️‍🩹", - aliases: ["mending_heart"], - tags: [], - category: "Smileys & Emotion", - description: "mending heart", - unicode_version: "13.1", - }, - { - emoji: "❤️", - aliases: ["heart"], - tags: ["love"], - category: "Smileys & Emotion", - description: "red heart", - unicode_version: "", - }, - { - emoji: "🧡", - aliases: ["orange_heart"], - tags: [], - category: "Smileys & Emotion", - description: "orange heart", - unicode_version: "11.0", - }, - { - emoji: "💛", - aliases: ["yellow_heart"], - tags: [], - category: "Smileys & Emotion", - description: "yellow heart", - unicode_version: "6.0", - }, - { - emoji: "💚", - aliases: ["green_heart"], - tags: [], - category: "Smileys & Emotion", - description: "green heart", - unicode_version: "6.0", - }, - { - emoji: "💙", - aliases: ["blue_heart"], - tags: [], - category: "Smileys & Emotion", - description: "blue heart", - unicode_version: "6.0", - }, - { - emoji: "💜", - aliases: ["purple_heart"], - tags: [], - category: "Smileys & Emotion", - description: "purple heart", - unicode_version: "6.0", - }, - { - emoji: "🤎", - aliases: ["brown_heart"], - tags: [], - category: "Smileys & Emotion", - description: "brown heart", - unicode_version: "12.0", - }, - { - emoji: "🖤", - aliases: ["black_heart"], - tags: [], - category: "Smileys & Emotion", - description: "black heart", - unicode_version: "9.0", - }, - { - emoji: "🤍", - aliases: ["white_heart"], - tags: [], - category: "Smileys & Emotion", - description: "white heart", - unicode_version: "12.0", - }, - { - emoji: "💯", - aliases: ["100"], - tags: ["score", "perfect"], - category: "Smileys & Emotion", - description: "hundred points", - unicode_version: "6.0", - }, - { - emoji: "💢", - aliases: ["anger"], - tags: ["angry"], - category: "Smileys & Emotion", - description: "anger symbol", - unicode_version: "6.0", - }, - { - emoji: "💥", - aliases: ["boom", "collision"], - tags: ["explode"], - category: "Smileys & Emotion", - description: "collision", - unicode_version: "6.0", - }, - { - emoji: "💫", - aliases: ["dizzy"], - tags: ["star"], - category: "Smileys & Emotion", - description: "dizzy", - unicode_version: "6.0", - }, - { - emoji: "💦", - aliases: ["sweat_drops"], - tags: ["water", "workout"], - category: "Smileys & Emotion", - description: "sweat droplets", - unicode_version: "6.0", - }, - { - emoji: "💨", - aliases: ["dash"], - tags: ["wind", "blow", "fast"], - category: "Smileys & Emotion", - description: "dashing away", - unicode_version: "6.0", - }, - { - emoji: "🕳️", - aliases: ["hole"], - tags: [], - category: "Smileys & Emotion", - description: "hole", - unicode_version: "7.0", - }, - { - emoji: "💣", - aliases: ["bomb"], - tags: ["boom"], - category: "Smileys & Emotion", - description: "bomb", - unicode_version: "6.0", - }, - { - emoji: "💬", - aliases: ["speech_balloon"], - tags: ["comment"], - category: "Smileys & Emotion", - description: "speech balloon", - unicode_version: "6.0", - }, - { - emoji: "👁️‍🗨️", - aliases: ["eye_speech_bubble"], - tags: [], - category: "Smileys & Emotion", - description: "eye in speech bubble", - unicode_version: "11.0", - }, - { - emoji: "🗨️", - aliases: ["left_speech_bubble"], - tags: [], - category: "Smileys & Emotion", - description: "left speech bubble", - unicode_version: "11.0", - }, - { - emoji: "🗯️", - aliases: ["right_anger_bubble"], - tags: [], - category: "Smileys & Emotion", - description: "right anger bubble", - unicode_version: "7.0", - }, - { - emoji: "💭", - aliases: ["thought_balloon"], - tags: ["thinking"], - category: "Smileys & Emotion", - description: "thought balloon", - unicode_version: "6.0", - }, - { - emoji: "💤", - aliases: ["zzz"], - tags: ["sleeping"], - category: "Smileys & Emotion", - description: "zzz", - unicode_version: "6.0", - }, - { - emoji: "👋", - aliases: ["wave"], - tags: ["goodbye"], - category: "People & Body", - description: "waving hand", - unicode_version: "6.0", - }, - { - emoji: "🤚", - aliases: ["raised_back_of_hand"], - tags: [], - category: "People & Body", - description: "raised back of hand", - unicode_version: "9.0", - }, - { - emoji: "🖐️", - aliases: ["raised_hand_with_fingers_splayed"], - tags: [], - category: "People & Body", - description: "hand with fingers splayed", - unicode_version: "7.0", - }, - { - emoji: "✋", - aliases: ["hand", "raised_hand"], - tags: ["highfive", "stop"], - category: "People & Body", - description: "raised hand", - unicode_version: "6.0", - }, - { - emoji: "🖖", - aliases: ["vulcan_salute"], - tags: ["prosper", "spock"], - category: "People & Body", - description: "vulcan salute", - unicode_version: "7.0", - }, - { - emoji: "👌", - aliases: ["ok_hand"], - tags: [], - category: "People & Body", - description: "OK hand", - unicode_version: "6.0", - }, - { - emoji: "🤌", - aliases: ["pinched_fingers"], - tags: [], - category: "People & Body", - description: "pinched fingers", - unicode_version: "13.0", - }, - { - emoji: "🤏", - aliases: ["pinching_hand"], - tags: [], - category: "People & Body", - description: "pinching hand", - unicode_version: "12.0", - }, - { - emoji: "✌️", - aliases: ["v"], - tags: ["victory", "peace"], - category: "People & Body", - description: "victory hand", - unicode_version: "", - }, - { - emoji: "🤞", - aliases: ["crossed_fingers"], - tags: ["luck", "hopeful"], - category: "People & Body", - description: "crossed fingers", - unicode_version: "9.0", - }, - { - emoji: "🤟", - aliases: ["love_you_gesture"], - tags: [], - category: "People & Body", - description: "love-you gesture", - unicode_version: "11.0", - }, - { - emoji: "🤘", - aliases: ["metal"], - tags: [], - category: "People & Body", - description: "sign of the horns", - unicode_version: "8.0", - }, - { - emoji: "🤙", - aliases: ["call_me_hand"], - tags: [], - category: "People & Body", - description: "call me hand", - unicode_version: "9.0", - }, - { - emoji: "👈", - aliases: ["point_left"], - tags: [], - category: "People & Body", - description: "backhand index pointing left", - unicode_version: "6.0", - }, - { - emoji: "👉", - aliases: ["point_right"], - tags: [], - category: "People & Body", - description: "backhand index pointing right", - unicode_version: "6.0", - }, - { - emoji: "👆", - aliases: ["point_up_2"], - tags: [], - category: "People & Body", - description: "backhand index pointing up", - unicode_version: "6.0", - }, - { - emoji: "🖕", - aliases: ["middle_finger", "fu"], - tags: [], - category: "People & Body", - description: "middle finger", - unicode_version: "7.0", - }, - { - emoji: "👇", - aliases: ["point_down"], - tags: [], - category: "People & Body", - description: "backhand index pointing down", - unicode_version: "6.0", - }, - { - emoji: "☝️", - aliases: ["point_up"], - tags: [], - category: "People & Body", - description: "index pointing up", - unicode_version: "", - }, - { - emoji: "👍", - aliases: ["+1", "thumbsup"], - tags: ["approve", "ok"], - category: "People & Body", - description: "thumbs up", - unicode_version: "6.0", - }, - { - emoji: "👎", - aliases: ["-1", "thumbsdown"], - tags: ["disapprove", "bury"], - category: "People & Body", - description: "thumbs down", - unicode_version: "6.0", - }, - { - emoji: "✊", - aliases: ["fist_raised", "fist"], - tags: ["power"], - category: "People & Body", - description: "raised fist", - unicode_version: "6.0", - }, - { - emoji: "👊", - aliases: ["fist_oncoming", "facepunch", "punch"], - tags: ["attack"], - category: "People & Body", - description: "oncoming fist", - unicode_version: "6.0", - }, - { - emoji: "🤛", - aliases: ["fist_left"], - tags: [], - category: "People & Body", - description: "left-facing fist", - unicode_version: "9.0", - }, - { - emoji: "🤜", - aliases: ["fist_right"], - tags: [], - category: "People & Body", - description: "right-facing fist", - unicode_version: "9.0", - }, - { - emoji: "👏", - aliases: ["clap"], - tags: ["praise", "applause"], - category: "People & Body", - description: "clapping hands", - unicode_version: "6.0", - }, - { - emoji: "🙌", - aliases: ["raised_hands"], - tags: ["hooray"], - category: "People & Body", - description: "raising hands", - unicode_version: "6.0", - }, - { - emoji: "👐", - aliases: ["open_hands"], - tags: [], - category: "People & Body", - description: "open hands", - unicode_version: "6.0", - }, - { - emoji: "🤲", - aliases: ["palms_up_together"], - tags: [], - category: "People & Body", - description: "palms up together", - unicode_version: "11.0", - }, - { - emoji: "🤝", - aliases: ["handshake"], - tags: ["deal"], - category: "People & Body", - description: "handshake", - unicode_version: "9.0", - }, - { - emoji: "🙏", - aliases: ["pray"], - tags: ["please", "hope", "wish"], - category: "People & Body", - description: "folded hands", - unicode_version: "6.0", - }, - { - emoji: "✍️", - aliases: ["writing_hand"], - tags: [], - category: "People & Body", - description: "writing hand", - unicode_version: "", - }, - { - emoji: "💅", - aliases: ["nail_care"], - tags: ["beauty", "manicure"], - category: "People & Body", - description: "nail polish", - unicode_version: "6.0", - }, - { - emoji: "🤳", - aliases: ["selfie"], - tags: [], - category: "People & Body", - description: "selfie", - unicode_version: "9.0", - }, - { - emoji: "💪", - aliases: ["muscle"], - tags: ["flex", "bicep", "strong", "workout"], - category: "People & Body", - description: "flexed biceps", - unicode_version: "6.0", - }, - { - emoji: "🦾", - aliases: ["mechanical_arm"], - tags: [], - category: "People & Body", - description: "mechanical arm", - unicode_version: "12.0", - }, - { - emoji: "🦿", - aliases: ["mechanical_leg"], - tags: [], - category: "People & Body", - description: "mechanical leg", - unicode_version: "12.0", - }, - { - emoji: "🦵", - aliases: ["leg"], - tags: [], - category: "People & Body", - description: "leg", - unicode_version: "11.0", - }, - { - emoji: "🦶", - aliases: ["foot"], - tags: [], - category: "People & Body", - description: "foot", - unicode_version: "11.0", - }, - { - emoji: "👂", - aliases: ["ear"], - tags: ["hear", "sound", "listen"], - category: "People & Body", - description: "ear", - unicode_version: "6.0", - }, - { - emoji: "🦻", - aliases: ["ear_with_hearing_aid"], - tags: [], - category: "People & Body", - description: "ear with hearing aid", - unicode_version: "12.0", - }, - { - emoji: "👃", - aliases: ["nose"], - tags: ["smell"], - category: "People & Body", - description: "nose", - unicode_version: "6.0", - }, - { - emoji: "🧠", - aliases: ["brain"], - tags: [], - category: "People & Body", - description: "brain", - unicode_version: "11.0", - }, - { - emoji: "🫀", - aliases: ["anatomical_heart"], - tags: [], - category: "People & Body", - description: "anatomical heart", - unicode_version: "13.0", - }, - { - emoji: "🫁", - aliases: ["lungs"], - tags: [], - category: "People & Body", - description: "lungs", - unicode_version: "13.0", - }, - { - emoji: "🦷", - aliases: ["tooth"], - tags: [], - category: "People & Body", - description: "tooth", - unicode_version: "11.0", - }, - { - emoji: "🦴", - aliases: ["bone"], - tags: [], - category: "People & Body", - description: "bone", - unicode_version: "11.0", - }, - { - emoji: "👀", - aliases: ["eyes"], - tags: ["look", "see", "watch"], - category: "People & Body", - description: "eyes", - unicode_version: "6.0", - }, - { - emoji: "👁️", - aliases: ["eye"], - tags: [], - category: "People & Body", - description: "eye", - unicode_version: "7.0", - }, - { - emoji: "👅", - aliases: ["tongue"], - tags: ["taste"], - category: "People & Body", - description: "tongue", - unicode_version: "6.0", - }, - { - emoji: "👄", - aliases: ["lips"], - tags: ["kiss"], - category: "People & Body", - description: "mouth", - unicode_version: "6.0", - }, - { - emoji: "👶", - aliases: ["baby"], - tags: ["child", "newborn"], - category: "People & Body", - description: "baby", - unicode_version: "6.0", - }, - { - emoji: "🧒", - aliases: ["child"], - tags: [], - category: "People & Body", - description: "child", - unicode_version: "11.0", - }, - { - emoji: "👦", - aliases: ["boy"], - tags: ["child"], - category: "People & Body", - description: "boy", - unicode_version: "6.0", - }, - { - emoji: "👧", - aliases: ["girl"], - tags: ["child"], - category: "People & Body", - description: "girl", - unicode_version: "6.0", - }, - { - emoji: "🧑", - aliases: ["adult"], - tags: [], - category: "People & Body", - description: "person", - unicode_version: "11.0", - }, - { - emoji: "👱", - aliases: ["blond_haired_person"], - tags: [], - category: "People & Body", - description: "person: blond hair", - unicode_version: "6.0", - }, - { - emoji: "👨", - aliases: ["man"], - tags: ["mustache", "father", "dad"], - category: "People & Body", - description: "man", - unicode_version: "6.0", - }, - { - emoji: "🧔", - aliases: ["bearded_person"], - tags: [], - category: "People & Body", - description: "person: beard", - unicode_version: "11.0", - }, - { - emoji: "🧔‍♂️", - aliases: ["man_beard"], - tags: [], - category: "People & Body", - description: "man: beard", - unicode_version: "13.1", - }, - { - emoji: "🧔‍♀️", - aliases: ["woman_beard"], - tags: [], - category: "People & Body", - description: "woman: beard", - unicode_version: "13.1", - }, - { - emoji: "👨‍🦰", - aliases: ["red_haired_man"], - tags: [], - category: "People & Body", - description: "man: red hair", - unicode_version: "11.0", - }, - { - emoji: "👨‍🦱", - aliases: ["curly_haired_man"], - tags: [], - category: "People & Body", - description: "man: curly hair", - unicode_version: "11.0", - }, - { - emoji: "👨‍🦳", - aliases: ["white_haired_man"], - tags: [], - category: "People & Body", - description: "man: white hair", - unicode_version: "11.0", - }, - { - emoji: "👨‍🦲", - aliases: ["bald_man"], - tags: [], - category: "People & Body", - description: "man: bald", - unicode_version: "11.0", - }, - { - emoji: "👩", - aliases: ["woman"], - tags: ["girls"], - category: "People & Body", - description: "woman", - unicode_version: "6.0", - }, - { - emoji: "👩‍🦰", - aliases: ["red_haired_woman"], - tags: [], - category: "People & Body", - description: "woman: red hair", - unicode_version: "11.0", - }, - { - emoji: "🧑‍🦰", - aliases: ["person_red_hair"], - tags: [], - category: "People & Body", - description: "person: red hair", - unicode_version: "12.1", - }, - { - emoji: "👩‍🦱", - aliases: ["curly_haired_woman"], - tags: [], - category: "People & Body", - description: "woman: curly hair", - unicode_version: "11.0", - }, - { - emoji: "🧑‍🦱", - aliases: ["person_curly_hair"], - tags: [], - category: "People & Body", - description: "person: curly hair", - unicode_version: "12.1", - }, - { - emoji: "👩‍🦳", - aliases: ["white_haired_woman"], - tags: [], - category: "People & Body", - description: "woman: white hair", - unicode_version: "11.0", - }, - { - emoji: "🧑‍🦳", - aliases: ["person_white_hair"], - tags: [], - category: "People & Body", - description: "person: white hair", - unicode_version: "12.1", - }, - { - emoji: "👩‍🦲", - aliases: ["bald_woman"], - tags: [], - category: "People & Body", - description: "woman: bald", - unicode_version: "11.0", - }, - { - emoji: "🧑‍🦲", - aliases: ["person_bald"], - tags: [], - category: "People & Body", - description: "person: bald", - unicode_version: "12.1", - }, - { - emoji: "👱‍♀️", - aliases: ["blond_haired_woman", "blonde_woman"], - tags: [], - category: "People & Body", - description: "woman: blond hair", - unicode_version: "6.0", - }, - { - emoji: "👱‍♂️", - aliases: ["blond_haired_man"], - tags: [], - category: "People & Body", - description: "man: blond hair", - unicode_version: "11.0", - }, - { - emoji: "🧓", - aliases: ["older_adult"], - tags: [], - category: "People & Body", - description: "older person", - unicode_version: "11.0", - }, - { - emoji: "👴", - aliases: ["older_man"], - tags: [], - category: "People & Body", - description: "old man", - unicode_version: "6.0", - }, - { - emoji: "👵", - aliases: ["older_woman"], - tags: [], - category: "People & Body", - description: "old woman", - unicode_version: "6.0", - }, - { - emoji: "🙍", - aliases: ["frowning_person"], - tags: [], - category: "People & Body", - description: "person frowning", - unicode_version: "6.0", - }, - { - emoji: "🙍‍♂️", - aliases: ["frowning_man"], - tags: [], - category: "People & Body", - description: "man frowning", - unicode_version: "6.0", - }, - { - emoji: "🙍‍♀️", - aliases: ["frowning_woman"], - tags: [], - category: "People & Body", - description: "woman frowning", - unicode_version: "11.0", - }, - { - emoji: "🙎", - aliases: ["pouting_face"], - tags: [], - category: "People & Body", - description: "person pouting", - unicode_version: "6.0", - }, - { - emoji: "🙎‍♂️", - aliases: ["pouting_man"], - tags: [], - category: "People & Body", - description: "man pouting", - unicode_version: "6.0", - }, - { - emoji: "🙎‍♀️", - aliases: ["pouting_woman"], - tags: [], - category: "People & Body", - description: "woman pouting", - unicode_version: "11.0", - }, - { - emoji: "🙅", - aliases: ["no_good"], - tags: ["stop", "halt", "denied"], - category: "People & Body", - description: "person gesturing NO", - unicode_version: "6.0", - }, - { - emoji: "🙅‍♂️", - aliases: ["no_good_man", "ng_man"], - tags: ["stop", "halt", "denied"], - category: "People & Body", - description: "man gesturing NO", - unicode_version: "6.0", - }, - { - emoji: "🙅‍♀️", - aliases: ["no_good_woman", "ng_woman"], - tags: ["stop", "halt", "denied"], - category: "People & Body", - description: "woman gesturing NO", - unicode_version: "11.0", - }, - { - emoji: "🙆", - aliases: ["ok_person"], - tags: [], - category: "People & Body", - description: "person gesturing OK", - unicode_version: "6.0", - }, - { - emoji: "🙆‍♂️", - aliases: ["ok_man"], - tags: [], - category: "People & Body", - description: "man gesturing OK", - unicode_version: "6.0", - }, - { - emoji: "🙆‍♀️", - aliases: ["ok_woman"], - tags: [], - category: "People & Body", - description: "woman gesturing OK", - unicode_version: "11.0", - }, - { - emoji: "💁", - aliases: ["tipping_hand_person", "information_desk_person"], - tags: [], - category: "People & Body", - description: "person tipping hand", - unicode_version: "6.0", - }, - { - emoji: "💁‍♂️", - aliases: ["tipping_hand_man", "sassy_man"], - tags: ["information"], - category: "People & Body", - description: "man tipping hand", - unicode_version: "6.0", - }, - { - emoji: "💁‍♀️", - aliases: ["tipping_hand_woman", "sassy_woman"], - tags: ["information"], - category: "People & Body", - description: "woman tipping hand", - unicode_version: "11.0", - }, - { - emoji: "🙋", - aliases: ["raising_hand"], - tags: [], - category: "People & Body", - description: "person raising hand", - unicode_version: "6.0", - }, - { - emoji: "🙋‍♂️", - aliases: ["raising_hand_man"], - tags: [], - category: "People & Body", - description: "man raising hand", - unicode_version: "6.0", - }, - { - emoji: "🙋‍♀️", - aliases: ["raising_hand_woman"], - tags: [], - category: "People & Body", - description: "woman raising hand", - unicode_version: "11.0", - }, - { - emoji: "🧏", - aliases: ["deaf_person"], - tags: [], - category: "People & Body", - description: "deaf person", - unicode_version: "12.0", - }, - { - emoji: "🧏‍♂️", - aliases: ["deaf_man"], - tags: [], - category: "People & Body", - description: "deaf man", - unicode_version: "12.0", - }, - { - emoji: "🧏‍♀️", - aliases: ["deaf_woman"], - tags: [], - category: "People & Body", - description: "deaf woman", - unicode_version: "12.0", - }, - { - emoji: "🙇", - aliases: ["bow"], - tags: ["respect", "thanks"], - category: "People & Body", - description: "person bowing", - unicode_version: "6.0", - }, - { - emoji: "🙇‍♂️", - aliases: ["bowing_man"], - tags: ["respect", "thanks"], - category: "People & Body", - description: "man bowing", - unicode_version: "11.0", - }, - { - emoji: "🙇‍♀️", - aliases: ["bowing_woman"], - tags: ["respect", "thanks"], - category: "People & Body", - description: "woman bowing", - unicode_version: "6.0", - }, - { - emoji: "🤦", - aliases: ["facepalm"], - tags: [], - category: "People & Body", - description: "person facepalming", - unicode_version: "11.0", - }, - { - emoji: "🤦‍♂️", - aliases: ["man_facepalming"], - tags: [], - category: "People & Body", - description: "man facepalming", - unicode_version: "9.0", - }, - { - emoji: "🤦‍♀️", - aliases: ["woman_facepalming"], - tags: [], - category: "People & Body", - description: "woman facepalming", - unicode_version: "9.0", - }, - { - emoji: "🤷", - aliases: ["shrug"], - tags: [], - category: "People & Body", - description: "person shrugging", - unicode_version: "11.0", - }, - { - emoji: "🤷‍♂️", - aliases: ["man_shrugging"], - tags: [], - category: "People & Body", - description: "man shrugging", - unicode_version: "9.0", - }, - { - emoji: "🤷‍♀️", - aliases: ["woman_shrugging"], - tags: [], - category: "People & Body", - description: "woman shrugging", - unicode_version: "9.0", - }, - { - emoji: "🧑‍⚕️", - aliases: ["health_worker"], - tags: [], - category: "People & Body", - description: "health worker", - unicode_version: "12.1", - }, - { - emoji: "👨‍⚕️", - aliases: ["man_health_worker"], - tags: ["doctor", "nurse"], - category: "People & Body", - description: "man health worker", - unicode_version: "", - }, - { - emoji: "👩‍⚕️", - aliases: ["woman_health_worker"], - tags: ["doctor", "nurse"], - category: "People & Body", - description: "woman health worker", - unicode_version: "", - }, - { - emoji: "🧑‍🎓", - aliases: ["student"], - tags: [], - category: "People & Body", - description: "student", - unicode_version: "12.1", - }, - { - emoji: "👨‍🎓", - aliases: ["man_student"], - tags: ["graduation"], - category: "People & Body", - description: "man student", - unicode_version: "", - }, - { - emoji: "👩‍🎓", - aliases: ["woman_student"], - tags: ["graduation"], - category: "People & Body", - description: "woman student", - unicode_version: "", - }, - { - emoji: "🧑‍🏫", - aliases: ["teacher"], - tags: [], - category: "People & Body", - description: "teacher", - unicode_version: "12.1", - }, - { - emoji: "👨‍🏫", - aliases: ["man_teacher"], - tags: ["school", "professor"], - category: "People & Body", - description: "man teacher", - unicode_version: "", - }, - { - emoji: "👩‍🏫", - aliases: ["woman_teacher"], - tags: ["school", "professor"], - category: "People & Body", - description: "woman teacher", - unicode_version: "", - }, - { - emoji: "🧑‍⚖️", - aliases: ["judge"], - tags: [], - category: "People & Body", - description: "judge", - unicode_version: "12.1", - }, - { - emoji: "👨‍⚖️", - aliases: ["man_judge"], - tags: ["justice"], - category: "People & Body", - description: "man judge", - unicode_version: "", - }, - { - emoji: "👩‍⚖️", - aliases: ["woman_judge"], - tags: ["justice"], - category: "People & Body", - description: "woman judge", - unicode_version: "", - }, - { - emoji: "🧑‍🌾", - aliases: ["farmer"], - tags: [], - category: "People & Body", - description: "farmer", - unicode_version: "12.1", - }, - { - emoji: "👨‍🌾", - aliases: ["man_farmer"], - tags: [], - category: "People & Body", - description: "man farmer", - unicode_version: "", - }, - { - emoji: "👩‍🌾", - aliases: ["woman_farmer"], - tags: [], - category: "People & Body", - description: "woman farmer", - unicode_version: "", - }, - { - emoji: "🧑‍🍳", - aliases: ["cook"], - tags: [], - category: "People & Body", - description: "cook", - unicode_version: "12.1", - }, - { - emoji: "👨‍🍳", - aliases: ["man_cook"], - tags: ["chef"], - category: "People & Body", - description: "man cook", - unicode_version: "", - }, - { - emoji: "👩‍🍳", - aliases: ["woman_cook"], - tags: ["chef"], - category: "People & Body", - description: "woman cook", - unicode_version: "", - }, - { - emoji: "🧑‍🔧", - aliases: ["mechanic"], - tags: [], - category: "People & Body", - description: "mechanic", - unicode_version: "12.1", - }, - { - emoji: "👨‍🔧", - aliases: ["man_mechanic"], - tags: [], - category: "People & Body", - description: "man mechanic", - unicode_version: "", - }, - { - emoji: "👩‍🔧", - aliases: ["woman_mechanic"], - tags: [], - category: "People & Body", - description: "woman mechanic", - unicode_version: "", - }, - { - emoji: "🧑‍🏭", - aliases: ["factory_worker"], - tags: [], - category: "People & Body", - description: "factory worker", - unicode_version: "12.1", - }, - { - emoji: "👨‍🏭", - aliases: ["man_factory_worker"], - tags: [], - category: "People & Body", - description: "man factory worker", - unicode_version: "", - }, - { - emoji: "👩‍🏭", - aliases: ["woman_factory_worker"], - tags: [], - category: "People & Body", - description: "woman factory worker", - unicode_version: "", - }, - { - emoji: "🧑‍💼", - aliases: ["office_worker"], - tags: [], - category: "People & Body", - description: "office worker", - unicode_version: "12.1", - }, - { - emoji: "👨‍💼", - aliases: ["man_office_worker"], - tags: ["business"], - category: "People & Body", - description: "man office worker", - unicode_version: "", - }, - { - emoji: "👩‍💼", - aliases: ["woman_office_worker"], - tags: ["business"], - category: "People & Body", - description: "woman office worker", - unicode_version: "", - }, - { - emoji: "🧑‍🔬", - aliases: ["scientist"], - tags: [], - category: "People & Body", - description: "scientist", - unicode_version: "12.1", - }, - { - emoji: "👨‍🔬", - aliases: ["man_scientist"], - tags: ["research"], - category: "People & Body", - description: "man scientist", - unicode_version: "", - }, - { - emoji: "👩‍🔬", - aliases: ["woman_scientist"], - tags: ["research"], - category: "People & Body", - description: "woman scientist", - unicode_version: "", - }, - { - emoji: "🧑‍💻", - aliases: ["technologist"], - tags: [], - category: "People & Body", - description: "technologist", - unicode_version: "12.1", - }, - { - emoji: "👨‍💻", - aliases: ["man_technologist"], - tags: ["coder"], - category: "People & Body", - description: "man technologist", - unicode_version: "", - }, - { - emoji: "👩‍💻", - aliases: ["woman_technologist"], - tags: ["coder"], - category: "People & Body", - description: "woman technologist", - unicode_version: "", - }, - { - emoji: "🧑‍🎤", - aliases: ["singer"], - tags: [], - category: "People & Body", - description: "singer", - unicode_version: "12.1", - }, - { - emoji: "👨‍🎤", - aliases: ["man_singer"], - tags: ["rockstar"], - category: "People & Body", - description: "man singer", - unicode_version: "", - }, - { - emoji: "👩‍🎤", - aliases: ["woman_singer"], - tags: ["rockstar"], - category: "People & Body", - description: "woman singer", - unicode_version: "", - }, - { - emoji: "🧑‍🎨", - aliases: ["artist"], - tags: [], - category: "People & Body", - description: "artist", - unicode_version: "12.1", - }, - { - emoji: "👨‍🎨", - aliases: ["man_artist"], - tags: ["painter"], - category: "People & Body", - description: "man artist", - unicode_version: "", - }, - { - emoji: "👩‍🎨", - aliases: ["woman_artist"], - tags: ["painter"], - category: "People & Body", - description: "woman artist", - unicode_version: "", - }, - { - emoji: "🧑‍✈️", - aliases: ["pilot"], - tags: [], - category: "People & Body", - description: "pilot", - unicode_version: "12.1", - }, - { - emoji: "👨‍✈️", - aliases: ["man_pilot"], - tags: [], - category: "People & Body", - description: "man pilot", - unicode_version: "", - }, - { - emoji: "👩‍✈️", - aliases: ["woman_pilot"], - tags: [], - category: "People & Body", - description: "woman pilot", - unicode_version: "", - }, - { - emoji: "🧑‍🚀", - aliases: ["astronaut"], - tags: [], - category: "People & Body", - description: "astronaut", - unicode_version: "12.1", - }, - { - emoji: "👨‍🚀", - aliases: ["man_astronaut"], - tags: ["space"], - category: "People & Body", - description: "man astronaut", - unicode_version: "", - }, - { - emoji: "👩‍🚀", - aliases: ["woman_astronaut"], - tags: ["space"], - category: "People & Body", - description: "woman astronaut", - unicode_version: "", - }, - { - emoji: "🧑‍🚒", - aliases: ["firefighter"], - tags: [], - category: "People & Body", - description: "firefighter", - unicode_version: "12.1", - }, - { - emoji: "👨‍🚒", - aliases: ["man_firefighter"], - tags: [], - category: "People & Body", - description: "man firefighter", - unicode_version: "", - }, - { - emoji: "👩‍🚒", - aliases: ["woman_firefighter"], - tags: [], - category: "People & Body", - description: "woman firefighter", - unicode_version: "", - }, - { - emoji: "👮", - aliases: ["police_officer", "cop"], - tags: ["law"], - category: "People & Body", - description: "police officer", - unicode_version: "6.0", - }, - { - emoji: "👮‍♂️", - aliases: ["policeman"], - tags: ["law", "cop"], - category: "People & Body", - description: "man police officer", - unicode_version: "11.0", - }, - { - emoji: "👮‍♀️", - aliases: ["policewoman"], - tags: ["law", "cop"], - category: "People & Body", - description: "woman police officer", - unicode_version: "6.0", - }, - { - emoji: "🕵️", - aliases: ["detective"], - tags: ["sleuth"], - category: "People & Body", - description: "detective", - unicode_version: "7.0", - }, - { - emoji: "🕵️‍♂️", - aliases: ["male_detective"], - tags: ["sleuth"], - category: "People & Body", - description: "man detective", - unicode_version: "11.0", - }, - { - emoji: "🕵️‍♀️", - aliases: ["female_detective"], - tags: ["sleuth"], - category: "People & Body", - description: "woman detective", - unicode_version: "6.0", - }, - { - emoji: "💂", - aliases: ["guard"], - tags: [], - category: "People & Body", - description: "guard", - unicode_version: "6.0", - }, - { - emoji: "💂‍♂️", - aliases: ["guardsman"], - tags: [], - category: "People & Body", - description: "man guard", - unicode_version: "11.0", - }, - { - emoji: "💂‍♀️", - aliases: ["guardswoman"], - tags: [], - category: "People & Body", - description: "woman guard", - unicode_version: "6.0", - }, - { - emoji: "🥷", - aliases: ["ninja"], - tags: [], - category: "People & Body", - description: "ninja", - unicode_version: "13.0", - }, - { - emoji: "👷", - aliases: ["construction_worker"], - tags: ["helmet"], - category: "People & Body", - description: "construction worker", - unicode_version: "6.0", - }, - { - emoji: "👷‍♂️", - aliases: ["construction_worker_man"], - tags: ["helmet"], - category: "People & Body", - description: "man construction worker", - unicode_version: "11.0", - }, - { - emoji: "👷‍♀️", - aliases: ["construction_worker_woman"], - tags: ["helmet"], - category: "People & Body", - description: "woman construction worker", - unicode_version: "6.0", - }, - { - emoji: "🤴", - aliases: ["prince"], - tags: ["crown", "royal"], - category: "People & Body", - description: "prince", - unicode_version: "9.0", - }, - { - emoji: "👸", - aliases: ["princess"], - tags: ["crown", "royal"], - category: "People & Body", - description: "princess", - unicode_version: "6.0", - }, - { - emoji: "👳", - aliases: ["person_with_turban"], - tags: [], - category: "People & Body", - description: "person wearing turban", - unicode_version: "6.0", - }, - { - emoji: "👳‍♂️", - aliases: ["man_with_turban"], - tags: [], - category: "People & Body", - description: "man wearing turban", - unicode_version: "11.0", - }, - { - emoji: "👳‍♀️", - aliases: ["woman_with_turban"], - tags: [], - category: "People & Body", - description: "woman wearing turban", - unicode_version: "6.0", - }, - { - emoji: "👲", - aliases: ["man_with_gua_pi_mao"], - tags: [], - category: "People & Body", - description: "person with skullcap", - unicode_version: "6.0", - }, - { - emoji: "🧕", - aliases: ["woman_with_headscarf"], - tags: ["hijab"], - category: "People & Body", - description: "woman with headscarf", - unicode_version: "11.0", - }, - { - emoji: "🤵", - aliases: ["person_in_tuxedo"], - tags: ["groom", "marriage", "wedding"], - category: "People & Body", - description: "person in tuxedo", - unicode_version: "9.0", - }, - { - emoji: "🤵‍♂️", - aliases: ["man_in_tuxedo"], - tags: [], - category: "People & Body", - description: "man in tuxedo", - unicode_version: "13.0", - }, - { - emoji: "🤵‍♀️", - aliases: ["woman_in_tuxedo"], - tags: [], - category: "People & Body", - description: "woman in tuxedo", - unicode_version: "13.0", - }, - { - emoji: "👰", - aliases: ["person_with_veil"], - tags: ["marriage", "wedding"], - category: "People & Body", - description: "person with veil", - unicode_version: "6.0", - }, - { - emoji: "👰‍♂️", - aliases: ["man_with_veil"], - tags: [], - category: "People & Body", - description: "man with veil", - unicode_version: "13.0", - }, - { - emoji: "👰‍♀️", - aliases: ["woman_with_veil", "bride_with_veil"], - tags: [], - category: "People & Body", - description: "woman with veil", - unicode_version: "13.0", - }, - { - emoji: "🤰", - aliases: ["pregnant_woman"], - tags: [], - category: "People & Body", - description: "pregnant woman", - unicode_version: "9.0", - }, - { - emoji: "🤱", - aliases: ["breast_feeding"], - tags: ["nursing"], - category: "People & Body", - description: "breast-feeding", - unicode_version: "11.0", - }, - { - emoji: "👩‍🍼", - aliases: ["woman_feeding_baby"], - tags: [], - category: "People & Body", - description: "woman feeding baby", - unicode_version: "13.0", - }, - { - emoji: "👨‍🍼", - aliases: ["man_feeding_baby"], - tags: [], - category: "People & Body", - description: "man feeding baby", - unicode_version: "13.0", - }, - { - emoji: "🧑‍🍼", - aliases: ["person_feeding_baby"], - tags: [], - category: "People & Body", - description: "person feeding baby", - unicode_version: "13.0", - }, - { - emoji: "👼", - aliases: ["angel"], - tags: [], - category: "People & Body", - description: "baby angel", - unicode_version: "6.0", - }, - { - emoji: "🎅", - aliases: ["santa"], - tags: ["christmas"], - category: "People & Body", - description: "Santa Claus", - unicode_version: "6.0", - }, - { - emoji: "🤶", - aliases: ["mrs_claus"], - tags: ["santa"], - category: "People & Body", - description: "Mrs. Claus", - unicode_version: "9.0", - }, - { - emoji: "🧑‍🎄", - aliases: ["mx_claus"], - tags: [], - category: "People & Body", - description: "mx claus", - unicode_version: "13.0", - }, - { - emoji: "🦸", - aliases: ["superhero"], - tags: [], - category: "People & Body", - description: "superhero", - unicode_version: "11.0", - }, - { - emoji: "🦸‍♂️", - aliases: ["superhero_man"], - tags: [], - category: "People & Body", - description: "man superhero", - unicode_version: "11.0", - }, - { - emoji: "🦸‍♀️", - aliases: ["superhero_woman"], - tags: [], - category: "People & Body", - description: "woman superhero", - unicode_version: "11.0", - }, - { - emoji: "🦹", - aliases: ["supervillain"], - tags: [], - category: "People & Body", - description: "supervillain", - unicode_version: "11.0", - }, - { - emoji: "🦹‍♂️", - aliases: ["supervillain_man"], - tags: [], - category: "People & Body", - description: "man supervillain", - unicode_version: "11.0", - }, - { - emoji: "🦹‍♀️", - aliases: ["supervillain_woman"], - tags: [], - category: "People & Body", - description: "woman supervillain", - unicode_version: "11.0", - }, - { - emoji: "🧙", - aliases: ["mage"], - tags: ["wizard"], - category: "People & Body", - description: "mage", - unicode_version: "11.0", - }, - { - emoji: "🧙‍♂️", - aliases: ["mage_man"], - tags: ["wizard"], - category: "People & Body", - description: "man mage", - unicode_version: "11.0", - }, - { - emoji: "🧙‍♀️", - aliases: ["mage_woman"], - tags: ["wizard"], - category: "People & Body", - description: "woman mage", - unicode_version: "11.0", - }, - { - emoji: "🧚", - aliases: ["fairy"], - tags: [], - category: "People & Body", - description: "fairy", - unicode_version: "11.0", - }, - { - emoji: "🧚‍♂️", - aliases: ["fairy_man"], - tags: [], - category: "People & Body", - description: "man fairy", - unicode_version: "11.0", - }, - { - emoji: "🧚‍♀️", - aliases: ["fairy_woman"], - tags: [], - category: "People & Body", - description: "woman fairy", - unicode_version: "11.0", - }, - { - emoji: "🧛", - aliases: ["vampire"], - tags: [], - category: "People & Body", - description: "vampire", - unicode_version: "11.0", - }, - { - emoji: "🧛‍♂️", - aliases: ["vampire_man"], - tags: [], - category: "People & Body", - description: "man vampire", - unicode_version: "11.0", - }, - { - emoji: "🧛‍♀️", - aliases: ["vampire_woman"], - tags: [], - category: "People & Body", - description: "woman vampire", - unicode_version: "11.0", - }, - { - emoji: "🧜", - aliases: ["merperson"], - tags: [], - category: "People & Body", - description: "merperson", - unicode_version: "11.0", - }, - { - emoji: "🧜‍♂️", - aliases: ["merman"], - tags: [], - category: "People & Body", - description: "merman", - unicode_version: "11.0", - }, - { - emoji: "🧜‍♀️", - aliases: ["mermaid"], - tags: [], - category: "People & Body", - description: "mermaid", - unicode_version: "11.0", - }, - { - emoji: "🧝", - aliases: ["elf"], - tags: [], - category: "People & Body", - description: "elf", - unicode_version: "11.0", - }, - { - emoji: "🧝‍♂️", - aliases: ["elf_man"], - tags: [], - category: "People & Body", - description: "man elf", - unicode_version: "11.0", - }, - { - emoji: "🧝‍♀️", - aliases: ["elf_woman"], - tags: [], - category: "People & Body", - description: "woman elf", - unicode_version: "11.0", - }, - { - emoji: "🧞", - aliases: ["genie"], - tags: [], - category: "People & Body", - description: "genie", - unicode_version: "11.0", - }, - { - emoji: "🧞‍♂️", - aliases: ["genie_man"], - tags: [], - category: "People & Body", - description: "man genie", - unicode_version: "11.0", - }, - { - emoji: "🧞‍♀️", - aliases: ["genie_woman"], - tags: [], - category: "People & Body", - description: "woman genie", - unicode_version: "11.0", - }, - { - emoji: "🧟", - aliases: ["zombie"], - tags: [], - category: "People & Body", - description: "zombie", - unicode_version: "11.0", - }, - { - emoji: "🧟‍♂️", - aliases: ["zombie_man"], - tags: [], - category: "People & Body", - description: "man zombie", - unicode_version: "11.0", - }, - { - emoji: "🧟‍♀️", - aliases: ["zombie_woman"], - tags: [], - category: "People & Body", - description: "woman zombie", - unicode_version: "11.0", - }, - { - emoji: "💆", - aliases: ["massage"], - tags: ["spa"], - category: "People & Body", - description: "person getting massage", - unicode_version: "6.0", - }, - { - emoji: "💆‍♂️", - aliases: ["massage_man"], - tags: ["spa"], - category: "People & Body", - description: "man getting massage", - unicode_version: "6.0", - }, - { - emoji: "💆‍♀️", - aliases: ["massage_woman"], - tags: ["spa"], - category: "People & Body", - description: "woman getting massage", - unicode_version: "11.0", - }, - { - emoji: "💇", - aliases: ["haircut"], - tags: ["beauty"], - category: "People & Body", - description: "person getting haircut", - unicode_version: "6.0", - }, - { - emoji: "💇‍♂️", - aliases: ["haircut_man"], - tags: [], - category: "People & Body", - description: "man getting haircut", - unicode_version: "6.0", - }, - { - emoji: "💇‍♀️", - aliases: ["haircut_woman"], - tags: [], - category: "People & Body", - description: "woman getting haircut", - unicode_version: "11.0", - }, - { - emoji: "🚶", - aliases: ["walking"], - tags: [], - category: "People & Body", - description: "person walking", - unicode_version: "6.0", - }, - { - emoji: "🚶‍♂️", - aliases: ["walking_man"], - tags: [], - category: "People & Body", - description: "man walking", - unicode_version: "11.0", - }, - { - emoji: "🚶‍♀️", - aliases: ["walking_woman"], - tags: [], - category: "People & Body", - description: "woman walking", - unicode_version: "6.0", - }, - { - emoji: "🧍", - aliases: ["standing_person"], - tags: [], - category: "People & Body", - description: "person standing", - unicode_version: "12.0", - }, - { - emoji: "🧍‍♂️", - aliases: ["standing_man"], - tags: [], - category: "People & Body", - description: "man standing", - unicode_version: "12.0", - }, - { - emoji: "🧍‍♀️", - aliases: ["standing_woman"], - tags: [], - category: "People & Body", - description: "woman standing", - unicode_version: "12.0", - }, - { - emoji: "🧎", - aliases: ["kneeling_person"], - tags: [], - category: "People & Body", - description: "person kneeling", - unicode_version: "12.0", - }, - { - emoji: "🧎‍♂️", - aliases: ["kneeling_man"], - tags: [], - category: "People & Body", - description: "man kneeling", - unicode_version: "12.0", - }, - { - emoji: "🧎‍♀️", - aliases: ["kneeling_woman"], - tags: [], - category: "People & Body", - description: "woman kneeling", - unicode_version: "12.0", - }, - { - emoji: "🧑‍🦯", - aliases: ["person_with_probing_cane"], - tags: [], - category: "People & Body", - description: "person with white cane", - unicode_version: "12.1", - }, - { - emoji: "👨‍🦯", - aliases: ["man_with_probing_cane"], - tags: [], - category: "People & Body", - description: "man with white cane", - unicode_version: "12.0", - }, - { - emoji: "👩‍🦯", - aliases: ["woman_with_probing_cane"], - tags: [], - category: "People & Body", - description: "woman with white cane", - unicode_version: "12.0", - }, - { - emoji: "🧑‍🦼", - aliases: ["person_in_motorized_wheelchair"], - tags: [], - category: "People & Body", - description: "person in motorized wheelchair", - unicode_version: "12.1", - }, - { - emoji: "👨‍🦼", - aliases: ["man_in_motorized_wheelchair"], - tags: [], - category: "People & Body", - description: "man in motorized wheelchair", - unicode_version: "12.0", - }, - { - emoji: "👩‍🦼", - aliases: ["woman_in_motorized_wheelchair"], - tags: [], - category: "People & Body", - description: "woman in motorized wheelchair", - unicode_version: "12.0", - }, - { - emoji: "🧑‍🦽", - aliases: ["person_in_manual_wheelchair"], - tags: [], - category: "People & Body", - description: "person in manual wheelchair", - unicode_version: "12.1", - }, - { - emoji: "👨‍🦽", - aliases: ["man_in_manual_wheelchair"], - tags: [], - category: "People & Body", - description: "man in manual wheelchair", - unicode_version: "12.0", - }, - { - emoji: "👩‍🦽", - aliases: ["woman_in_manual_wheelchair"], - tags: [], - category: "People & Body", - description: "woman in manual wheelchair", - unicode_version: "12.0", - }, - { - emoji: "🏃", - aliases: ["runner", "running"], - tags: ["exercise", "workout", "marathon"], - category: "People & Body", - description: "person running", - unicode_version: "6.0", - }, - { - emoji: "🏃‍♂️", - aliases: ["running_man"], - tags: ["exercise", "workout", "marathon"], - category: "People & Body", - description: "man running", - unicode_version: "11.0", - }, - { - emoji: "🏃‍♀️", - aliases: ["running_woman"], - tags: ["exercise", "workout", "marathon"], - category: "People & Body", - description: "woman running", - unicode_version: "6.0", - }, - { - emoji: "💃", - aliases: ["woman_dancing", "dancer"], - tags: ["dress"], - category: "People & Body", - description: "woman dancing", - unicode_version: "6.0", - }, - { - emoji: "🕺", - aliases: ["man_dancing"], - tags: ["dancer"], - category: "People & Body", - description: "man dancing", - unicode_version: "9.0", - }, - { - emoji: "🕴️", - aliases: ["business_suit_levitating"], - tags: [], - category: "People & Body", - description: "person in suit levitating", - unicode_version: "7.0", - }, - { - emoji: "👯", - aliases: ["dancers"], - tags: ["bunny"], - category: "People & Body", - description: "people with bunny ears", - unicode_version: "6.0", - }, - { - emoji: "👯‍♂️", - aliases: ["dancing_men"], - tags: ["bunny"], - category: "People & Body", - description: "men with bunny ears", - unicode_version: "6.0", - }, - { - emoji: "👯‍♀️", - aliases: ["dancing_women"], - tags: ["bunny"], - category: "People & Body", - description: "women with bunny ears", - unicode_version: "11.0", - }, - { - emoji: "🧖", - aliases: ["sauna_person"], - tags: ["steamy"], - category: "People & Body", - description: "person in steamy room", - unicode_version: "11.0", - }, - { - emoji: "🧖‍♂️", - aliases: ["sauna_man"], - tags: ["steamy"], - category: "People & Body", - description: "man in steamy room", - unicode_version: "11.0", - }, - { - emoji: "🧖‍♀️", - aliases: ["sauna_woman"], - tags: ["steamy"], - category: "People & Body", - description: "woman in steamy room", - unicode_version: "11.0", - }, - { - emoji: "🧗", - aliases: ["climbing"], - tags: ["bouldering"], - category: "People & Body", - description: "person climbing", - unicode_version: "11.0", - }, - { - emoji: "🧗‍♂️", - aliases: ["climbing_man"], - tags: ["bouldering"], - category: "People & Body", - description: "man climbing", - unicode_version: "11.0", - }, - { - emoji: "🧗‍♀️", - aliases: ["climbing_woman"], - tags: ["bouldering"], - category: "People & Body", - description: "woman climbing", - unicode_version: "11.0", - }, - { - emoji: "🤺", - aliases: ["person_fencing"], - tags: [], - category: "People & Body", - description: "person fencing", - unicode_version: "9.0", - }, - { - emoji: "🏇", - aliases: ["horse_racing"], - tags: [], - category: "People & Body", - description: "horse racing", - unicode_version: "6.0", - }, - { - emoji: "⛷️", - aliases: ["skier"], - tags: [], - category: "People & Body", - description: "skier", - unicode_version: "5.2", - }, - { - emoji: "🏂", - aliases: ["snowboarder"], - tags: [], - category: "People & Body", - description: "snowboarder", - unicode_version: "6.0", - }, - { - emoji: "🏌️", - aliases: ["golfing"], - tags: [], - category: "People & Body", - description: "person golfing", - unicode_version: "7.0", - }, - { - emoji: "🏌️‍♂️", - aliases: ["golfing_man"], - tags: [], - category: "People & Body", - description: "man golfing", - unicode_version: "11.0", - }, - { - emoji: "🏌️‍♀️", - aliases: ["golfing_woman"], - tags: [], - category: "People & Body", - description: "woman golfing", - unicode_version: "", - }, - { - emoji: "🏄", - aliases: ["surfer"], - tags: [], - category: "People & Body", - description: "person surfing", - unicode_version: "6.0", - }, - { - emoji: "🏄‍♂️", - aliases: ["surfing_man"], - tags: [], - category: "People & Body", - description: "man surfing", - unicode_version: "11.0", - }, - { - emoji: "🏄‍♀️", - aliases: ["surfing_woman"], - tags: [], - category: "People & Body", - description: "woman surfing", - unicode_version: "7.0", - }, - { - emoji: "🚣", - aliases: ["rowboat"], - tags: [], - category: "People & Body", - description: "person rowing boat", - unicode_version: "6.0", - }, - { - emoji: "🚣‍♂️", - aliases: ["rowing_man"], - tags: [], - category: "People & Body", - description: "man rowing boat", - unicode_version: "11.0", - }, - { - emoji: "🚣‍♀️", - aliases: ["rowing_woman"], - tags: [], - category: "People & Body", - description: "woman rowing boat", - unicode_version: "6.0", - }, - { - emoji: "🏊", - aliases: ["swimmer"], - tags: [], - category: "People & Body", - description: "person swimming", - unicode_version: "6.0", - }, - { - emoji: "🏊‍♂️", - aliases: ["swimming_man"], - tags: [], - category: "People & Body", - description: "man swimming", - unicode_version: "11.0", - }, - { - emoji: "🏊‍♀️", - aliases: ["swimming_woman"], - tags: [], - category: "People & Body", - description: "woman swimming", - unicode_version: "6.0", - }, - { - emoji: "⛹️", - aliases: ["bouncing_ball_person"], - tags: ["basketball"], - category: "People & Body", - description: "person bouncing ball", - unicode_version: "5.2", - }, - { - emoji: "⛹️‍♂️", - aliases: ["bouncing_ball_man", "basketball_man"], - tags: [], - category: "People & Body", - description: "man bouncing ball", - unicode_version: "11.0", - }, - { - emoji: "⛹️‍♀️", - aliases: ["bouncing_ball_woman", "basketball_woman"], - tags: [], - category: "People & Body", - description: "woman bouncing ball", - unicode_version: "7.0", - }, - { - emoji: "🏋️", - aliases: ["weight_lifting"], - tags: ["gym", "workout"], - category: "People & Body", - description: "person lifting weights", - unicode_version: "7.0", - }, - { - emoji: "🏋️‍♂️", - aliases: ["weight_lifting_man"], - tags: ["gym", "workout"], - category: "People & Body", - description: "man lifting weights", - unicode_version: "11.0", - }, - { - emoji: "🏋️‍♀️", - aliases: ["weight_lifting_woman"], - tags: ["gym", "workout"], - category: "People & Body", - description: "woman lifting weights", - unicode_version: "6.0", - }, - { - emoji: "🚴", - aliases: ["bicyclist"], - tags: [], - category: "People & Body", - description: "person biking", - unicode_version: "6.0", - }, - { - emoji: "🚴‍♂️", - aliases: ["biking_man"], - tags: [], - category: "People & Body", - description: "man biking", - unicode_version: "11.0", - }, - { - emoji: "🚴‍♀️", - aliases: ["biking_woman"], - tags: [], - category: "People & Body", - description: "woman biking", - unicode_version: "6.0", - }, - { - emoji: "🚵", - aliases: ["mountain_bicyclist"], - tags: [], - category: "People & Body", - description: "person mountain biking", - unicode_version: "6.0", - }, - { - emoji: "🚵‍♂️", - aliases: ["mountain_biking_man"], - tags: [], - category: "People & Body", - description: "man mountain biking", - unicode_version: "11.0", - }, - { - emoji: "🚵‍♀️", - aliases: ["mountain_biking_woman"], - tags: [], - category: "People & Body", - description: "woman mountain biking", - unicode_version: "6.0", - }, - { - emoji: "🤸", - aliases: ["cartwheeling"], - tags: [], - category: "People & Body", - description: "person cartwheeling", - unicode_version: "11.0", - }, - { - emoji: "🤸‍♂️", - aliases: ["man_cartwheeling"], - tags: [], - category: "People & Body", - description: "man cartwheeling", - unicode_version: "", - }, - { - emoji: "🤸‍♀️", - aliases: ["woman_cartwheeling"], - tags: [], - category: "People & Body", - description: "woman cartwheeling", - unicode_version: "", - }, - { - emoji: "🤼", - aliases: ["wrestling"], - tags: [], - category: "People & Body", - description: "people wrestling", - unicode_version: "11.0", - }, - { - emoji: "🤼‍♂️", - aliases: ["men_wrestling"], - tags: [], - category: "People & Body", - description: "men wrestling", - unicode_version: "9.0", - }, - { - emoji: "🤼‍♀️", - aliases: ["women_wrestling"], - tags: [], - category: "People & Body", - description: "women wrestling", - unicode_version: "9.0", - }, - { - emoji: "🤽", - aliases: ["water_polo"], - tags: [], - category: "People & Body", - description: "person playing water polo", - unicode_version: "11.0", - }, - { - emoji: "🤽‍♂️", - aliases: ["man_playing_water_polo"], - tags: [], - category: "People & Body", - description: "man playing water polo", - unicode_version: "9.0", - }, - { - emoji: "🤽‍♀️", - aliases: ["woman_playing_water_polo"], - tags: [], - category: "People & Body", - description: "woman playing water polo", - unicode_version: "9.0", - }, - { - emoji: "🤾", - aliases: ["handball_person"], - tags: [], - category: "People & Body", - description: "person playing handball", - unicode_version: "11.0", - }, - { - emoji: "🤾‍♂️", - aliases: ["man_playing_handball"], - tags: [], - category: "People & Body", - description: "man playing handball", - unicode_version: "9.0", - }, - { - emoji: "🤾‍♀️", - aliases: ["woman_playing_handball"], - tags: [], - category: "People & Body", - description: "woman playing handball", - unicode_version: "9.0", - }, - { - emoji: "🤹", - aliases: ["juggling_person"], - tags: [], - category: "People & Body", - description: "person juggling", - unicode_version: "11.0", - }, - { - emoji: "🤹‍♂️", - aliases: ["man_juggling"], - tags: [], - category: "People & Body", - description: "man juggling", - unicode_version: "9.0", - }, - { - emoji: "🤹‍♀️", - aliases: ["woman_juggling"], - tags: [], - category: "People & Body", - description: "woman juggling", - unicode_version: "9.0", - }, - { - emoji: "🧘", - aliases: ["lotus_position"], - tags: ["meditation"], - category: "People & Body", - description: "person in lotus position", - unicode_version: "11.0", - }, - { - emoji: "🧘‍♂️", - aliases: ["lotus_position_man"], - tags: ["meditation"], - category: "People & Body", - description: "man in lotus position", - unicode_version: "11.0", - }, - { - emoji: "🧘‍♀️", - aliases: ["lotus_position_woman"], - tags: ["meditation"], - category: "People & Body", - description: "woman in lotus position", - unicode_version: "11.0", - }, - { - emoji: "🛀", - aliases: ["bath"], - tags: ["shower"], - category: "People & Body", - description: "person taking bath", - unicode_version: "6.0", - }, - { - emoji: "🛌", - aliases: ["sleeping_bed"], - tags: [], - category: "People & Body", - description: "person in bed", - unicode_version: "7.0", - }, - { - emoji: "🧑‍🤝‍🧑", - aliases: ["people_holding_hands"], - tags: ["couple", "date"], - category: "People & Body", - description: "people holding hands", - unicode_version: "12.0", - }, - { - emoji: "👭", - aliases: ["two_women_holding_hands"], - tags: ["couple", "date"], - category: "People & Body", - description: "women holding hands", - unicode_version: "6.0", - }, - { - emoji: "👫", - aliases: ["couple"], - tags: ["date"], - category: "People & Body", - description: "woman and man holding hands", - unicode_version: "6.0", - }, - { - emoji: "👬", - aliases: ["two_men_holding_hands"], - tags: ["couple", "date"], - category: "People & Body", - description: "men holding hands", - unicode_version: "6.0", - }, - { - emoji: "💏", - aliases: ["couplekiss"], - tags: [], - category: "People & Body", - description: "kiss", - unicode_version: "6.0", - }, - { - emoji: "👩‍❤️‍💋‍👨", - aliases: ["couplekiss_man_woman"], - tags: [], - category: "People & Body", - description: "kiss: woman, man", - unicode_version: "11.0", - }, - { - emoji: "👨‍❤️‍💋‍👨", - aliases: ["couplekiss_man_man"], - tags: [], - category: "People & Body", - description: "kiss: man, man", - unicode_version: "6.0", - }, - { - emoji: "👩‍❤️‍💋‍👩", - aliases: ["couplekiss_woman_woman"], - tags: [], - category: "People & Body", - description: "kiss: woman, woman", - unicode_version: "6.0", - }, - { - emoji: "💑", - aliases: ["couple_with_heart"], - tags: [], - category: "People & Body", - description: "couple with heart", - unicode_version: "6.0", - }, - { - emoji: "👩‍❤️‍👨", - aliases: ["couple_with_heart_woman_man"], - tags: [], - category: "People & Body", - description: "couple with heart: woman, man", - unicode_version: "11.0", - }, - { - emoji: "👨‍❤️‍👨", - aliases: ["couple_with_heart_man_man"], - tags: [], - category: "People & Body", - description: "couple with heart: man, man", - unicode_version: "6.0", - }, - { - emoji: "👩‍❤️‍👩", - aliases: ["couple_with_heart_woman_woman"], - tags: [], - category: "People & Body", - description: "couple with heart: woman, woman", - unicode_version: "6.0", - }, - { - emoji: "👪", - aliases: ["family"], - tags: ["home", "parents", "child"], - category: "People & Body", - description: "family", - unicode_version: "6.0", - }, - { - emoji: "👨‍👩‍👦", - aliases: ["family_man_woman_boy"], - tags: [], - category: "People & Body", - description: "family: man, woman, boy", - unicode_version: "11.0", - }, - { - emoji: "👨‍👩‍👧", - aliases: ["family_man_woman_girl"], - tags: [], - category: "People & Body", - description: "family: man, woman, girl", - unicode_version: "6.0", - }, - { - emoji: "👨‍👩‍👧‍👦", - aliases: ["family_man_woman_girl_boy"], - tags: [], - category: "People & Body", - description: "family: man, woman, girl, boy", - unicode_version: "6.0", - }, - { - emoji: "👨‍👩‍👦‍👦", - aliases: ["family_man_woman_boy_boy"], - tags: [], - category: "People & Body", - description: "family: man, woman, boy, boy", - unicode_version: "6.0", - }, - { - emoji: "👨‍👩‍👧‍👧", - aliases: ["family_man_woman_girl_girl"], - tags: [], - category: "People & Body", - description: "family: man, woman, girl, girl", - unicode_version: "6.0", - }, - { - emoji: "👨‍👨‍👦", - aliases: ["family_man_man_boy"], - tags: [], - category: "People & Body", - description: "family: man, man, boy", - unicode_version: "6.0", - }, - { - emoji: "👨‍👨‍👧", - aliases: ["family_man_man_girl"], - tags: [], - category: "People & Body", - description: "family: man, man, girl", - unicode_version: "6.0", - }, - { - emoji: "👨‍👨‍👧‍👦", - aliases: ["family_man_man_girl_boy"], - tags: [], - category: "People & Body", - description: "family: man, man, girl, boy", - unicode_version: "6.0", - }, - { - emoji: "👨‍👨‍👦‍👦", - aliases: ["family_man_man_boy_boy"], - tags: [], - category: "People & Body", - description: "family: man, man, boy, boy", - unicode_version: "6.0", - }, - { - emoji: "👨‍👨‍👧‍👧", - aliases: ["family_man_man_girl_girl"], - tags: [], - category: "People & Body", - description: "family: man, man, girl, girl", - unicode_version: "6.0", - }, - { - emoji: "👩‍👩‍👦", - aliases: ["family_woman_woman_boy"], - tags: [], - category: "People & Body", - description: "family: woman, woman, boy", - unicode_version: "6.0", - }, - { - emoji: "👩‍👩‍👧", - aliases: ["family_woman_woman_girl"], - tags: [], - category: "People & Body", - description: "family: woman, woman, girl", - unicode_version: "6.0", - }, - { - emoji: "👩‍👩‍👧‍👦", - aliases: ["family_woman_woman_girl_boy"], - tags: [], - category: "People & Body", - description: "family: woman, woman, girl, boy", - unicode_version: "6.0", - }, - { - emoji: "👩‍👩‍👦‍👦", - aliases: ["family_woman_woman_boy_boy"], - tags: [], - category: "People & Body", - description: "family: woman, woman, boy, boy", - unicode_version: "6.0", - }, - { - emoji: "👩‍👩‍👧‍👧", - aliases: ["family_woman_woman_girl_girl"], - tags: [], - category: "People & Body", - description: "family: woman, woman, girl, girl", - unicode_version: "6.0", - }, - { - emoji: "👨‍👦", - aliases: ["family_man_boy"], - tags: [], - category: "People & Body", - description: "family: man, boy", - unicode_version: "6.0", - }, - { - emoji: "👨‍👦‍👦", - aliases: ["family_man_boy_boy"], - tags: [], - category: "People & Body", - description: "family: man, boy, boy", - unicode_version: "6.0", - }, - { - emoji: "👨‍👧", - aliases: ["family_man_girl"], - tags: [], - category: "People & Body", - description: "family: man, girl", - unicode_version: "6.0", - }, - { - emoji: "👨‍👧‍👦", - aliases: ["family_man_girl_boy"], - tags: [], - category: "People & Body", - description: "family: man, girl, boy", - unicode_version: "6.0", - }, - { - emoji: "👨‍👧‍👧", - aliases: ["family_man_girl_girl"], - tags: [], - category: "People & Body", - description: "family: man, girl, girl", - unicode_version: "6.0", - }, - { - emoji: "👩‍👦", - aliases: ["family_woman_boy"], - tags: [], - category: "People & Body", - description: "family: woman, boy", - unicode_version: "6.0", - }, - { - emoji: "👩‍👦‍👦", - aliases: ["family_woman_boy_boy"], - tags: [], - category: "People & Body", - description: "family: woman, boy, boy", - unicode_version: "6.0", - }, - { - emoji: "👩‍👧", - aliases: ["family_woman_girl"], - tags: [], - category: "People & Body", - description: "family: woman, girl", - unicode_version: "6.0", - }, - { - emoji: "👩‍👧‍👦", - aliases: ["family_woman_girl_boy"], - tags: [], - category: "People & Body", - description: "family: woman, girl, boy", - unicode_version: "6.0", - }, - { - emoji: "👩‍👧‍👧", - aliases: ["family_woman_girl_girl"], - tags: [], - category: "People & Body", - description: "family: woman, girl, girl", - unicode_version: "6.0", - }, - { - emoji: "🗣️", - aliases: ["speaking_head"], - tags: [], - category: "People & Body", - description: "speaking head", - unicode_version: "7.0", - }, - { - emoji: "👤", - aliases: ["bust_in_silhouette"], - tags: ["user"], - category: "People & Body", - description: "bust in silhouette", - unicode_version: "6.0", - }, - { - emoji: "👥", - aliases: ["busts_in_silhouette"], - tags: ["users", "group", "team"], - category: "People & Body", - description: "busts in silhouette", - unicode_version: "6.0", - }, - { - emoji: "🫂", - aliases: ["people_hugging"], - tags: [], - category: "People & Body", - description: "people hugging", - unicode_version: "13.0", - }, - { - emoji: "👣", - aliases: ["footprints"], - tags: ["feet", "tracks"], - category: "People & Body", - description: "footprints", - unicode_version: "6.0", - }, - { - emoji: "🐵", - aliases: ["monkey_face"], - tags: [], - category: "Animals & Nature", - description: "monkey face", - unicode_version: "6.0", - }, - { - emoji: "🐒", - aliases: ["monkey"], - tags: [], - category: "Animals & Nature", - description: "monkey", - unicode_version: "6.0", - }, - { - emoji: "🦍", - aliases: ["gorilla"], - tags: [], - category: "Animals & Nature", - description: "gorilla", - unicode_version: "9.0", - }, - { - emoji: "🦧", - aliases: ["orangutan"], - tags: [], - category: "Animals & Nature", - description: "orangutan", - unicode_version: "12.0", - }, - { - emoji: "🐶", - aliases: ["dog"], - tags: ["pet"], - category: "Animals & Nature", - description: "dog face", - unicode_version: "6.0", - }, - { - emoji: "🐕", - aliases: ["dog2"], - tags: [], - category: "Animals & Nature", - description: "dog", - unicode_version: "6.0", - }, - { - emoji: "🦮", - aliases: ["guide_dog"], - tags: [], - category: "Animals & Nature", - description: "guide dog", - unicode_version: "12.0", - }, - { - emoji: "🐕‍🦺", - aliases: ["service_dog"], - tags: [], - category: "Animals & Nature", - description: "service dog", - unicode_version: "12.0", - }, - { - emoji: "🐩", - aliases: ["poodle"], - tags: ["dog"], - category: "Animals & Nature", - description: "poodle", - unicode_version: "6.0", - }, - { - emoji: "🐺", - aliases: ["wolf"], - tags: [], - category: "Animals & Nature", - description: "wolf", - unicode_version: "6.0", - }, - { - emoji: "🦊", - aliases: ["fox_face"], - tags: [], - category: "Animals & Nature", - description: "fox", - unicode_version: "9.0", - }, - { - emoji: "🦝", - aliases: ["raccoon"], - tags: [], - category: "Animals & Nature", - description: "raccoon", - unicode_version: "11.0", - }, - { - emoji: "🐱", - aliases: ["cat"], - tags: ["pet"], - category: "Animals & Nature", - description: "cat face", - unicode_version: "6.0", - }, - { - emoji: "🐈", - aliases: ["cat2"], - tags: [], - category: "Animals & Nature", - description: "cat", - unicode_version: "6.0", - }, - { - emoji: "🐈‍⬛", - aliases: ["black_cat"], - tags: [], - category: "Animals & Nature", - description: "black cat", - unicode_version: "13.0", - }, - { - emoji: "🦁", - aliases: ["lion"], - tags: [], - category: "Animals & Nature", - description: "lion", - unicode_version: "8.0", - }, - { - emoji: "🐯", - aliases: ["tiger"], - tags: [], - category: "Animals & Nature", - description: "tiger face", - unicode_version: "6.0", - }, - { - emoji: "🐅", - aliases: ["tiger2"], - tags: [], - category: "Animals & Nature", - description: "tiger", - unicode_version: "6.0", - }, - { - emoji: "🐆", - aliases: ["leopard"], - tags: [], - category: "Animals & Nature", - description: "leopard", - unicode_version: "6.0", - }, - { - emoji: "🐴", - aliases: ["horse"], - tags: [], - category: "Animals & Nature", - description: "horse face", - unicode_version: "6.0", - }, - { - emoji: "🐎", - aliases: ["racehorse"], - tags: ["speed"], - category: "Animals & Nature", - description: "horse", - unicode_version: "6.0", - }, - { - emoji: "🦄", - aliases: ["unicorn"], - tags: [], - category: "Animals & Nature", - description: "unicorn", - unicode_version: "8.0", - }, - { - emoji: "🦓", - aliases: ["zebra"], - tags: [], - category: "Animals & Nature", - description: "zebra", - unicode_version: "11.0", - }, - { - emoji: "🦌", - aliases: ["deer"], - tags: [], - category: "Animals & Nature", - description: "deer", - unicode_version: "9.0", - }, - { - emoji: "🦬", - aliases: ["bison"], - tags: [], - category: "Animals & Nature", - description: "bison", - unicode_version: "13.0", - }, - { - emoji: "🐮", - aliases: ["cow"], - tags: [], - category: "Animals & Nature", - description: "cow face", - unicode_version: "6.0", - }, - { - emoji: "🐂", - aliases: ["ox"], - tags: [], - category: "Animals & Nature", - description: "ox", - unicode_version: "6.0", - }, - { - emoji: "🐃", - aliases: ["water_buffalo"], - tags: [], - category: "Animals & Nature", - description: "water buffalo", - unicode_version: "6.0", - }, - { - emoji: "🐄", - aliases: ["cow2"], - tags: [], - category: "Animals & Nature", - description: "cow", - unicode_version: "6.0", - }, - { - emoji: "🐷", - aliases: ["pig"], - tags: [], - category: "Animals & Nature", - description: "pig face", - unicode_version: "6.0", - }, - { - emoji: "🐖", - aliases: ["pig2"], - tags: [], - category: "Animals & Nature", - description: "pig", - unicode_version: "6.0", - }, - { - emoji: "🐗", - aliases: ["boar"], - tags: [], - category: "Animals & Nature", - description: "boar", - unicode_version: "6.0", - }, - { - emoji: "🐽", - aliases: ["pig_nose"], - tags: [], - category: "Animals & Nature", - description: "pig nose", - unicode_version: "6.0", - }, - { - emoji: "🐏", - aliases: ["ram"], - tags: [], - category: "Animals & Nature", - description: "ram", - unicode_version: "6.0", - }, - { - emoji: "🐑", - aliases: ["sheep"], - tags: [], - category: "Animals & Nature", - description: "ewe", - unicode_version: "6.0", - }, - { - emoji: "🐐", - aliases: ["goat"], - tags: [], - category: "Animals & Nature", - description: "goat", - unicode_version: "6.0", - }, - { - emoji: "🐪", - aliases: ["dromedary_camel"], - tags: ["desert"], - category: "Animals & Nature", - description: "camel", - unicode_version: "6.0", - }, - { - emoji: "🐫", - aliases: ["camel"], - tags: [], - category: "Animals & Nature", - description: "two-hump camel", - unicode_version: "6.0", - }, - { - emoji: "🦙", - aliases: ["llama"], - tags: [], - category: "Animals & Nature", - description: "llama", - unicode_version: "11.0", - }, - { - emoji: "🦒", - aliases: ["giraffe"], - tags: [], - category: "Animals & Nature", - description: "giraffe", - unicode_version: "11.0", - }, - { - emoji: "🐘", - aliases: ["elephant"], - tags: [], - category: "Animals & Nature", - description: "elephant", - unicode_version: "6.0", - }, - { - emoji: "🦣", - aliases: ["mammoth"], - tags: [], - category: "Animals & Nature", - description: "mammoth", - unicode_version: "13.0", - }, - { - emoji: "🦏", - aliases: ["rhinoceros"], - tags: [], - category: "Animals & Nature", - description: "rhinoceros", - unicode_version: "9.0", - }, - { - emoji: "🦛", - aliases: ["hippopotamus"], - tags: [], - category: "Animals & Nature", - description: "hippopotamus", - unicode_version: "11.0", - }, - { - emoji: "🐭", - aliases: ["mouse"], - tags: [], - category: "Animals & Nature", - description: "mouse face", - unicode_version: "6.0", - }, - { - emoji: "🐁", - aliases: ["mouse2"], - tags: [], - category: "Animals & Nature", - description: "mouse", - unicode_version: "6.0", - }, - { - emoji: "🐀", - aliases: ["rat"], - tags: [], - category: "Animals & Nature", - description: "rat", - unicode_version: "6.0", - }, - { - emoji: "🐹", - aliases: ["hamster"], - tags: ["pet"], - category: "Animals & Nature", - description: "hamster", - unicode_version: "6.0", - }, - { - emoji: "🐰", - aliases: ["rabbit"], - tags: ["bunny"], - category: "Animals & Nature", - description: "rabbit face", - unicode_version: "6.0", - }, - { - emoji: "🐇", - aliases: ["rabbit2"], - tags: [], - category: "Animals & Nature", - description: "rabbit", - unicode_version: "6.0", - }, - { - emoji: "🐿️", - aliases: ["chipmunk"], - tags: [], - category: "Animals & Nature", - description: "chipmunk", - unicode_version: "7.0", - }, - { - emoji: "🦫", - aliases: ["beaver"], - tags: [], - category: "Animals & Nature", - description: "beaver", - unicode_version: "13.0", - }, - { - emoji: "🦔", - aliases: ["hedgehog"], - tags: [], - category: "Animals & Nature", - description: "hedgehog", - unicode_version: "11.0", - }, - { - emoji: "🦇", - aliases: ["bat"], - tags: [], - category: "Animals & Nature", - description: "bat", - unicode_version: "9.0", - }, - { - emoji: "🐻", - aliases: ["bear"], - tags: [], - category: "Animals & Nature", - description: "bear", - unicode_version: "6.0", - }, - { - emoji: "🐻‍❄️", - aliases: ["polar_bear"], - tags: [], - category: "Animals & Nature", - description: "polar bear", - unicode_version: "13.0", - }, - { - emoji: "🐨", - aliases: ["koala"], - tags: [], - category: "Animals & Nature", - description: "koala", - unicode_version: "6.0", - }, - { - emoji: "🐼", - aliases: ["panda_face"], - tags: [], - category: "Animals & Nature", - description: "panda", - unicode_version: "6.0", - }, - { - emoji: "🦥", - aliases: ["sloth"], - tags: [], - category: "Animals & Nature", - description: "sloth", - unicode_version: "12.0", - }, - { - emoji: "🦦", - aliases: ["otter"], - tags: [], - category: "Animals & Nature", - description: "otter", - unicode_version: "12.0", - }, - { - emoji: "🦨", - aliases: ["skunk"], - tags: [], - category: "Animals & Nature", - description: "skunk", - unicode_version: "12.0", - }, - { - emoji: "🦘", - aliases: ["kangaroo"], - tags: [], - category: "Animals & Nature", - description: "kangaroo", - unicode_version: "11.0", - }, - { - emoji: "🦡", - aliases: ["badger"], - tags: [], - category: "Animals & Nature", - description: "badger", - unicode_version: "11.0", - }, - { - emoji: "🐾", - aliases: ["feet", "paw_prints"], - tags: [], - category: "Animals & Nature", - description: "paw prints", - unicode_version: "6.0", - }, - { - emoji: "🦃", - aliases: ["turkey"], - tags: ["thanksgiving"], - category: "Animals & Nature", - description: "turkey", - unicode_version: "8.0", - }, - { - emoji: "🐔", - aliases: ["chicken"], - tags: [], - category: "Animals & Nature", - description: "chicken", - unicode_version: "6.0", - }, - { - emoji: "🐓", - aliases: ["rooster"], - tags: [], - category: "Animals & Nature", - description: "rooster", - unicode_version: "6.0", - }, - { - emoji: "🐣", - aliases: ["hatching_chick"], - tags: [], - category: "Animals & Nature", - description: "hatching chick", - unicode_version: "6.0", - }, - { - emoji: "🐤", - aliases: ["baby_chick"], - tags: [], - category: "Animals & Nature", - description: "baby chick", - unicode_version: "6.0", - }, - { - emoji: "🐥", - aliases: ["hatched_chick"], - tags: [], - category: "Animals & Nature", - description: "front-facing baby chick", - unicode_version: "6.0", - }, - { - emoji: "🐦", - aliases: ["bird"], - tags: [], - category: "Animals & Nature", - description: "bird", - unicode_version: "6.0", - }, - { - emoji: "🐧", - aliases: ["penguin"], - tags: [], - category: "Animals & Nature", - description: "penguin", - unicode_version: "6.0", - }, - { - emoji: "🕊️", - aliases: ["dove"], - tags: ["peace"], - category: "Animals & Nature", - description: "dove", - unicode_version: "7.0", - }, - { - emoji: "🦅", - aliases: ["eagle"], - tags: [], - category: "Animals & Nature", - description: "eagle", - unicode_version: "9.0", - }, - { - emoji: "🦆", - aliases: ["duck"], - tags: [], - category: "Animals & Nature", - description: "duck", - unicode_version: "9.0", - }, - { - emoji: "🦢", - aliases: ["swan"], - tags: [], - category: "Animals & Nature", - description: "swan", - unicode_version: "11.0", - }, - { - emoji: "🦉", - aliases: ["owl"], - tags: [], - category: "Animals & Nature", - description: "owl", - unicode_version: "9.0", - }, - { - emoji: "🦤", - aliases: ["dodo"], - tags: [], - category: "Animals & Nature", - description: "dodo", - unicode_version: "13.0", - }, - { - emoji: "🪶", - aliases: ["feather"], - tags: [], - category: "Animals & Nature", - description: "feather", - unicode_version: "13.0", - }, - { - emoji: "🦩", - aliases: ["flamingo"], - tags: [], - category: "Animals & Nature", - description: "flamingo", - unicode_version: "12.0", - }, - { - emoji: "🦚", - aliases: ["peacock"], - tags: [], - category: "Animals & Nature", - description: "peacock", - unicode_version: "11.0", - }, - { - emoji: "🦜", - aliases: ["parrot"], - tags: [], - category: "Animals & Nature", - description: "parrot", - unicode_version: "11.0", - }, - { - emoji: "🐸", - aliases: ["frog"], - tags: [], - category: "Animals & Nature", - description: "frog", - unicode_version: "6.0", - }, - { - emoji: "🐊", - aliases: ["crocodile"], - tags: [], - category: "Animals & Nature", - description: "crocodile", - unicode_version: "6.0", - }, - { - emoji: "🐢", - aliases: ["turtle"], - tags: ["slow"], - category: "Animals & Nature", - description: "turtle", - unicode_version: "6.0", - }, - { - emoji: "🦎", - aliases: ["lizard"], - tags: [], - category: "Animals & Nature", - description: "lizard", - unicode_version: "9.0", - }, - { - emoji: "🐍", - aliases: ["snake"], - tags: [], - category: "Animals & Nature", - description: "snake", - unicode_version: "6.0", - }, - { - emoji: "🐲", - aliases: ["dragon_face"], - tags: [], - category: "Animals & Nature", - description: "dragon face", - unicode_version: "6.0", - }, - { - emoji: "🐉", - aliases: ["dragon"], - tags: [], - category: "Animals & Nature", - description: "dragon", - unicode_version: "6.0", - }, - { - emoji: "🦕", - aliases: ["sauropod"], - tags: ["dinosaur"], - category: "Animals & Nature", - description: "sauropod", - unicode_version: "11.0", - }, - { - emoji: "🦖", - aliases: ["t-rex"], - tags: ["dinosaur"], - category: "Animals & Nature", - description: "T-Rex", - unicode_version: "11.0", - }, - { - emoji: "🐳", - aliases: ["whale"], - tags: ["sea"], - category: "Animals & Nature", - description: "spouting whale", - unicode_version: "6.0", - }, - { - emoji: "🐋", - aliases: ["whale2"], - tags: [], - category: "Animals & Nature", - description: "whale", - unicode_version: "6.0", - }, - { - emoji: "🐬", - aliases: ["dolphin", "flipper"], - tags: [], - category: "Animals & Nature", - description: "dolphin", - unicode_version: "6.0", - }, - { - emoji: "🦭", - aliases: ["seal"], - tags: [], - category: "Animals & Nature", - description: "seal", - unicode_version: "13.0", - }, - { - emoji: "🐟", - aliases: ["fish"], - tags: [], - category: "Animals & Nature", - description: "fish", - unicode_version: "6.0", - }, - { - emoji: "🐠", - aliases: ["tropical_fish"], - tags: [], - category: "Animals & Nature", - description: "tropical fish", - unicode_version: "6.0", - }, - { - emoji: "🐡", - aliases: ["blowfish"], - tags: [], - category: "Animals & Nature", - description: "blowfish", - unicode_version: "6.0", - }, - { - emoji: "🦈", - aliases: ["shark"], - tags: [], - category: "Animals & Nature", - description: "shark", - unicode_version: "9.0", - }, - { - emoji: "🐙", - aliases: ["octopus"], - tags: [], - category: "Animals & Nature", - description: "octopus", - unicode_version: "6.0", - }, - { - emoji: "🐚", - aliases: ["shell"], - tags: ["sea", "beach"], - category: "Animals & Nature", - description: "spiral shell", - unicode_version: "6.0", - }, - { - emoji: "🐌", - aliases: ["snail"], - tags: ["slow"], - category: "Animals & Nature", - description: "snail", - unicode_version: "6.0", - }, - { - emoji: "🦋", - aliases: ["butterfly"], - tags: [], - category: "Animals & Nature", - description: "butterfly", - unicode_version: "9.0", - }, - { - emoji: "🐛", - aliases: ["bug"], - tags: [], - category: "Animals & Nature", - description: "bug", - unicode_version: "6.0", - }, - { - emoji: "🐜", - aliases: ["ant"], - tags: [], - category: "Animals & Nature", - description: "ant", - unicode_version: "6.0", - }, - { - emoji: "🐝", - aliases: ["bee", "honeybee"], - tags: [], - category: "Animals & Nature", - description: "honeybee", - unicode_version: "6.0", - }, - { - emoji: "🪲", - aliases: ["beetle"], - tags: [], - category: "Animals & Nature", - description: "beetle", - unicode_version: "13.0", - }, - { - emoji: "🐞", - aliases: ["lady_beetle"], - tags: ["bug"], - category: "Animals & Nature", - description: "lady beetle", - unicode_version: "6.0", - }, - { - emoji: "🦗", - aliases: ["cricket"], - tags: [], - category: "Animals & Nature", - description: "cricket", - unicode_version: "11.0", - }, - { - emoji: "🪳", - aliases: ["cockroach"], - tags: [], - category: "Animals & Nature", - description: "cockroach", - unicode_version: "13.0", - }, - { - emoji: "🕷️", - aliases: ["spider"], - tags: [], - category: "Animals & Nature", - description: "spider", - unicode_version: "7.0", - }, - { - emoji: "🕸️", - aliases: ["spider_web"], - tags: [], - category: "Animals & Nature", - description: "spider web", - unicode_version: "7.0", - }, - { - emoji: "🦂", - aliases: ["scorpion"], - tags: [], - category: "Animals & Nature", - description: "scorpion", - unicode_version: "8.0", - }, - { - emoji: "🦟", - aliases: ["mosquito"], - tags: [], - category: "Animals & Nature", - description: "mosquito", - unicode_version: "11.0", - }, - { - emoji: "🪰", - aliases: ["fly"], - tags: [], - category: "Animals & Nature", - description: "fly", - unicode_version: "13.0", - }, - { - emoji: "🪱", - aliases: ["worm"], - tags: [], - category: "Animals & Nature", - description: "worm", - unicode_version: "13.0", - }, - { - emoji: "🦠", - aliases: ["microbe"], - tags: ["germ"], - category: "Animals & Nature", - description: "microbe", - unicode_version: "11.0", - }, - { - emoji: "💐", - aliases: ["bouquet"], - tags: ["flowers"], - category: "Animals & Nature", - description: "bouquet", - unicode_version: "6.0", - }, - { - emoji: "🌸", - aliases: ["cherry_blossom"], - tags: ["flower", "spring"], - category: "Animals & Nature", - description: "cherry blossom", - unicode_version: "6.0", - }, - { - emoji: "💮", - aliases: ["white_flower"], - tags: [], - category: "Animals & Nature", - description: "white flower", - unicode_version: "6.0", - }, - { - emoji: "🏵️", - aliases: ["rosette"], - tags: [], - category: "Animals & Nature", - description: "rosette", - unicode_version: "7.0", - }, - { - emoji: "🌹", - aliases: ["rose"], - tags: ["flower"], - category: "Animals & Nature", - description: "rose", - unicode_version: "6.0", - }, - { - emoji: "🥀", - aliases: ["wilted_flower"], - tags: [], - category: "Animals & Nature", - description: "wilted flower", - unicode_version: "9.0", - }, - { - emoji: "🌺", - aliases: ["hibiscus"], - tags: [], - category: "Animals & Nature", - description: "hibiscus", - unicode_version: "6.0", - }, - { - emoji: "🌻", - aliases: ["sunflower"], - tags: [], - category: "Animals & Nature", - description: "sunflower", - unicode_version: "6.0", - }, - { - emoji: "🌼", - aliases: ["blossom"], - tags: [], - category: "Animals & Nature", - description: "blossom", - unicode_version: "6.0", - }, - { - emoji: "🌷", - aliases: ["tulip"], - tags: ["flower"], - category: "Animals & Nature", - description: "tulip", - unicode_version: "6.0", - }, - { - emoji: "🌱", - aliases: ["seedling"], - tags: ["plant"], - category: "Animals & Nature", - description: "seedling", - unicode_version: "6.0", - }, - { - emoji: "🪴", - aliases: ["potted_plant"], - tags: [], - category: "Animals & Nature", - description: "potted plant", - unicode_version: "13.0", - }, - { - emoji: "🌲", - aliases: ["evergreen_tree"], - tags: ["wood"], - category: "Animals & Nature", - description: "evergreen tree", - unicode_version: "6.0", - }, - { - emoji: "🌳", - aliases: ["deciduous_tree"], - tags: ["wood"], - category: "Animals & Nature", - description: "deciduous tree", - unicode_version: "6.0", - }, - { - emoji: "🌴", - aliases: ["palm_tree"], - tags: [], - category: "Animals & Nature", - description: "palm tree", - unicode_version: "6.0", - }, - { - emoji: "🌵", - aliases: ["cactus"], - tags: [], - category: "Animals & Nature", - description: "cactus", - unicode_version: "6.0", - }, - { - emoji: "🌾", - aliases: ["ear_of_rice"], - tags: [], - category: "Animals & Nature", - description: "sheaf of rice", - unicode_version: "6.0", - }, - { - emoji: "🌿", - aliases: ["herb"], - tags: [], - category: "Animals & Nature", - description: "herb", - unicode_version: "6.0", - }, - { - emoji: "☘️", - aliases: ["shamrock"], - tags: [], - category: "Animals & Nature", - description: "shamrock", - unicode_version: "4.1", - }, - { - emoji: "🍀", - aliases: ["four_leaf_clover"], - tags: ["luck"], - category: "Animals & Nature", - description: "four leaf clover", - unicode_version: "6.0", - }, - { - emoji: "🍁", - aliases: ["maple_leaf"], - tags: ["canada"], - category: "Animals & Nature", - description: "maple leaf", - unicode_version: "6.0", - }, - { - emoji: "🍂", - aliases: ["fallen_leaf"], - tags: ["autumn"], - category: "Animals & Nature", - description: "fallen leaf", - unicode_version: "6.0", - }, - { - emoji: "🍃", - aliases: ["leaves"], - tags: ["leaf"], - category: "Animals & Nature", - description: "leaf fluttering in wind", - unicode_version: "6.0", - }, - { - emoji: "🍇", - aliases: ["grapes"], - tags: [], - category: "Food & Drink", - description: "grapes", - unicode_version: "6.0", - }, - { - emoji: "🍈", - aliases: ["melon"], - tags: [], - category: "Food & Drink", - description: "melon", - unicode_version: "6.0", - }, - { - emoji: "🍉", - aliases: ["watermelon"], - tags: [], - category: "Food & Drink", - description: "watermelon", - unicode_version: "6.0", - }, - { - emoji: "🍊", - aliases: ["tangerine", "orange", "mandarin"], - tags: [], - category: "Food & Drink", - description: "tangerine", - unicode_version: "6.0", - }, - { - emoji: "🍋", - aliases: ["lemon"], - tags: [], - category: "Food & Drink", - description: "lemon", - unicode_version: "6.0", - }, - { - emoji: "🍌", - aliases: ["banana"], - tags: ["fruit"], - category: "Food & Drink", - description: "banana", - unicode_version: "6.0", - }, - { - emoji: "🍍", - aliases: ["pineapple"], - tags: [], - category: "Food & Drink", - description: "pineapple", - unicode_version: "6.0", - }, - { - emoji: "🥭", - aliases: ["mango"], - tags: [], - category: "Food & Drink", - description: "mango", - unicode_version: "11.0", - }, - { - emoji: "🍎", - aliases: ["apple"], - tags: [], - category: "Food & Drink", - description: "red apple", - unicode_version: "6.0", - }, - { - emoji: "🍏", - aliases: ["green_apple"], - tags: ["fruit"], - category: "Food & Drink", - description: "green apple", - unicode_version: "6.0", - }, - { - emoji: "🍐", - aliases: ["pear"], - tags: [], - category: "Food & Drink", - description: "pear", - unicode_version: "6.0", - }, - { - emoji: "🍑", - aliases: ["peach"], - tags: [], - category: "Food & Drink", - description: "peach", - unicode_version: "6.0", - }, - { - emoji: "🍒", - aliases: ["cherries"], - tags: ["fruit"], - category: "Food & Drink", - description: "cherries", - unicode_version: "6.0", - }, - { - emoji: "🍓", - aliases: ["strawberry"], - tags: ["fruit"], - category: "Food & Drink", - description: "strawberry", - unicode_version: "6.0", - }, - { - emoji: "🫐", - aliases: ["blueberries"], - tags: [], - category: "Food & Drink", - description: "blueberries", - unicode_version: "13.0", - }, - { - emoji: "🥝", - aliases: ["kiwi_fruit"], - tags: [], - category: "Food & Drink", - description: "kiwi fruit", - unicode_version: "9.0", - }, - { - emoji: "🍅", - aliases: ["tomato"], - tags: [], - category: "Food & Drink", - description: "tomato", - unicode_version: "6.0", - }, - { - emoji: "🫒", - aliases: ["olive"], - tags: [], - category: "Food & Drink", - description: "olive", - unicode_version: "13.0", - }, - { - emoji: "🥥", - aliases: ["coconut"], - tags: [], - category: "Food & Drink", - description: "coconut", - unicode_version: "11.0", - }, - { - emoji: "🥑", - aliases: ["avocado"], - tags: [], - category: "Food & Drink", - description: "avocado", - unicode_version: "9.0", - }, - { - emoji: "🍆", - aliases: ["eggplant"], - tags: ["aubergine"], - category: "Food & Drink", - description: "eggplant", - unicode_version: "6.0", - }, - { - emoji: "🥔", - aliases: ["potato"], - tags: [], - category: "Food & Drink", - description: "potato", - unicode_version: "9.0", - }, - { - emoji: "🥕", - aliases: ["carrot"], - tags: [], - category: "Food & Drink", - description: "carrot", - unicode_version: "9.0", - }, - { - emoji: "🌽", - aliases: ["corn"], - tags: [], - category: "Food & Drink", - description: "ear of corn", - unicode_version: "6.0", - }, - { - emoji: "🌶️", - aliases: ["hot_pepper"], - tags: ["spicy"], - category: "Food & Drink", - description: "hot pepper", - unicode_version: "7.0", - }, - { - emoji: "🫑", - aliases: ["bell_pepper"], - tags: [], - category: "Food & Drink", - description: "bell pepper", - unicode_version: "13.0", - }, - { - emoji: "🥒", - aliases: ["cucumber"], - tags: [], - category: "Food & Drink", - description: "cucumber", - unicode_version: "9.0", - }, - { - emoji: "🥬", - aliases: ["leafy_green"], - tags: [], - category: "Food & Drink", - description: "leafy green", - unicode_version: "11.0", - }, - { - emoji: "🥦", - aliases: ["broccoli"], - tags: [], - category: "Food & Drink", - description: "broccoli", - unicode_version: "11.0", - }, - { - emoji: "🧄", - aliases: ["garlic"], - tags: [], - category: "Food & Drink", - description: "garlic", - unicode_version: "12.0", - }, - { - emoji: "🧅", - aliases: ["onion"], - tags: [], - category: "Food & Drink", - description: "onion", - unicode_version: "12.0", - }, - { - emoji: "🍄", - aliases: ["mushroom"], - tags: [], - category: "Food & Drink", - description: "mushroom", - unicode_version: "6.0", - }, - { - emoji: "🥜", - aliases: ["peanuts"], - tags: [], - category: "Food & Drink", - description: "peanuts", - unicode_version: "9.0", - }, - { - emoji: "🌰", - aliases: ["chestnut"], - tags: [], - category: "Food & Drink", - description: "chestnut", - unicode_version: "6.0", - }, - { - emoji: "🍞", - aliases: ["bread"], - tags: ["toast"], - category: "Food & Drink", - description: "bread", - unicode_version: "6.0", - }, - { - emoji: "🥐", - aliases: ["croissant"], - tags: [], - category: "Food & Drink", - description: "croissant", - unicode_version: "9.0", - }, - { - emoji: "🥖", - aliases: ["baguette_bread"], - tags: [], - category: "Food & Drink", - description: "baguette bread", - unicode_version: "9.0", - }, - { - emoji: "🫓", - aliases: ["flatbread"], - tags: [], - category: "Food & Drink", - description: "flatbread", - unicode_version: "13.0", - }, - { - emoji: "🥨", - aliases: ["pretzel"], - tags: [], - category: "Food & Drink", - description: "pretzel", - unicode_version: "11.0", - }, - { - emoji: "🥯", - aliases: ["bagel"], - tags: [], - category: "Food & Drink", - description: "bagel", - unicode_version: "11.0", - }, - { - emoji: "🥞", - aliases: ["pancakes"], - tags: [], - category: "Food & Drink", - description: "pancakes", - unicode_version: "9.0", - }, - { - emoji: "🧇", - aliases: ["waffle"], - tags: [], - category: "Food & Drink", - description: "waffle", - unicode_version: "12.0", - }, - { - emoji: "🧀", - aliases: ["cheese"], - tags: [], - category: "Food & Drink", - description: "cheese wedge", - unicode_version: "8.0", - }, - { - emoji: "🍖", - aliases: ["meat_on_bone"], - tags: [], - category: "Food & Drink", - description: "meat on bone", - unicode_version: "6.0", - }, - { - emoji: "🍗", - aliases: ["poultry_leg"], - tags: ["meat", "chicken"], - category: "Food & Drink", - description: "poultry leg", - unicode_version: "6.0", - }, - { - emoji: "🥩", - aliases: ["cut_of_meat"], - tags: [], - category: "Food & Drink", - description: "cut of meat", - unicode_version: "11.0", - }, - { - emoji: "🥓", - aliases: ["bacon"], - tags: [], - category: "Food & Drink", - description: "bacon", - unicode_version: "9.0", - }, - { - emoji: "🍔", - aliases: ["hamburger"], - tags: ["burger"], - category: "Food & Drink", - description: "hamburger", - unicode_version: "6.0", - }, - { - emoji: "🍟", - aliases: ["fries"], - tags: [], - category: "Food & Drink", - description: "french fries", - unicode_version: "6.0", - }, - { - emoji: "🍕", - aliases: ["pizza"], - tags: [], - category: "Food & Drink", - description: "pizza", - unicode_version: "6.0", - }, - { - emoji: "🌭", - aliases: ["hotdog"], - tags: [], - category: "Food & Drink", - description: "hot dog", - unicode_version: "8.0", - }, - { - emoji: "🥪", - aliases: ["sandwich"], - tags: [], - category: "Food & Drink", - description: "sandwich", - unicode_version: "11.0", - }, - { - emoji: "🌮", - aliases: ["taco"], - tags: [], - category: "Food & Drink", - description: "taco", - unicode_version: "8.0", - }, - { - emoji: "🌯", - aliases: ["burrito"], - tags: [], - category: "Food & Drink", - description: "burrito", - unicode_version: "8.0", - }, - { - emoji: "🫔", - aliases: ["tamale"], - tags: [], - category: "Food & Drink", - description: "tamale", - unicode_version: "13.0", - }, - { - emoji: "🥙", - aliases: ["stuffed_flatbread"], - tags: [], - category: "Food & Drink", - description: "stuffed flatbread", - unicode_version: "9.0", - }, - { - emoji: "🧆", - aliases: ["falafel"], - tags: [], - category: "Food & Drink", - description: "falafel", - unicode_version: "12.0", - }, - { - emoji: "🥚", - aliases: ["egg"], - tags: [], - category: "Food & Drink", - description: "egg", - unicode_version: "9.0", - }, - { - emoji: "🍳", - aliases: ["fried_egg"], - tags: ["breakfast"], - category: "Food & Drink", - description: "cooking", - unicode_version: "6.0", - }, - { - emoji: "🥘", - aliases: ["shallow_pan_of_food"], - tags: ["paella", "curry"], - category: "Food & Drink", - description: "shallow pan of food", - unicode_version: "", - }, - { - emoji: "🍲", - aliases: ["stew"], - tags: [], - category: "Food & Drink", - description: "pot of food", - unicode_version: "6.0", - }, - { - emoji: "🫕", - aliases: ["fondue"], - tags: [], - category: "Food & Drink", - description: "fondue", - unicode_version: "13.0", - }, - { - emoji: "🥣", - aliases: ["bowl_with_spoon"], - tags: [], - category: "Food & Drink", - description: "bowl with spoon", - unicode_version: "11.0", - }, - { - emoji: "🥗", - aliases: ["green_salad"], - tags: [], - category: "Food & Drink", - description: "green salad", - unicode_version: "9.0", - }, - { - emoji: "🍿", - aliases: ["popcorn"], - tags: [], - category: "Food & Drink", - description: "popcorn", - unicode_version: "8.0", - }, - { - emoji: "🧈", - aliases: ["butter"], - tags: [], - category: "Food & Drink", - description: "butter", - unicode_version: "12.0", - }, - { - emoji: "🧂", - aliases: ["salt"], - tags: [], - category: "Food & Drink", - description: "salt", - unicode_version: "11.0", - }, - { - emoji: "🥫", - aliases: ["canned_food"], - tags: [], - category: "Food & Drink", - description: "canned food", - unicode_version: "11.0", - }, - { - emoji: "🍱", - aliases: ["bento"], - tags: [], - category: "Food & Drink", - description: "bento box", - unicode_version: "6.0", - }, - { - emoji: "🍘", - aliases: ["rice_cracker"], - tags: [], - category: "Food & Drink", - description: "rice cracker", - unicode_version: "6.0", - }, - { - emoji: "🍙", - aliases: ["rice_ball"], - tags: [], - category: "Food & Drink", - description: "rice ball", - unicode_version: "6.0", - }, - { - emoji: "🍚", - aliases: ["rice"], - tags: [], - category: "Food & Drink", - description: "cooked rice", - unicode_version: "6.0", - }, - { - emoji: "🍛", - aliases: ["curry"], - tags: [], - category: "Food & Drink", - description: "curry rice", - unicode_version: "6.0", - }, - { - emoji: "🍜", - aliases: ["ramen"], - tags: ["noodle"], - category: "Food & Drink", - description: "steaming bowl", - unicode_version: "6.0", - }, - { - emoji: "🍝", - aliases: ["spaghetti"], - tags: ["pasta"], - category: "Food & Drink", - description: "spaghetti", - unicode_version: "6.0", - }, - { - emoji: "🍠", - aliases: ["sweet_potato"], - tags: [], - category: "Food & Drink", - description: "roasted sweet potato", - unicode_version: "6.0", - }, - { - emoji: "🍢", - aliases: ["oden"], - tags: [], - category: "Food & Drink", - description: "oden", - unicode_version: "6.0", - }, - { - emoji: "🍣", - aliases: ["sushi"], - tags: [], - category: "Food & Drink", - description: "sushi", - unicode_version: "6.0", - }, - { - emoji: "🍤", - aliases: ["fried_shrimp"], - tags: ["tempura"], - category: "Food & Drink", - description: "fried shrimp", - unicode_version: "6.0", - }, - { - emoji: "🍥", - aliases: ["fish_cake"], - tags: [], - category: "Food & Drink", - description: "fish cake with swirl", - unicode_version: "6.0", - }, - { - emoji: "🥮", - aliases: ["moon_cake"], - tags: [], - category: "Food & Drink", - description: "moon cake", - unicode_version: "11.0", - }, - { - emoji: "🍡", - aliases: ["dango"], - tags: [], - category: "Food & Drink", - description: "dango", - unicode_version: "6.0", - }, - { - emoji: "🥟", - aliases: ["dumpling"], - tags: [], - category: "Food & Drink", - description: "dumpling", - unicode_version: "11.0", - }, - { - emoji: "🥠", - aliases: ["fortune_cookie"], - tags: [], - category: "Food & Drink", - description: "fortune cookie", - unicode_version: "11.0", - }, - { - emoji: "🥡", - aliases: ["takeout_box"], - tags: [], - category: "Food & Drink", - description: "takeout box", - unicode_version: "11.0", - }, - { - emoji: "🦀", - aliases: ["crab"], - tags: [], - category: "Food & Drink", - description: "crab", - unicode_version: "8.0", - }, - { - emoji: "🦞", - aliases: ["lobster"], - tags: [], - category: "Food & Drink", - description: "lobster", - unicode_version: "11.0", - }, - { - emoji: "🦐", - aliases: ["shrimp"], - tags: [], - category: "Food & Drink", - description: "shrimp", - unicode_version: "9.0", - }, - { - emoji: "🦑", - aliases: ["squid"], - tags: [], - category: "Food & Drink", - description: "squid", - unicode_version: "9.0", - }, - { - emoji: "🦪", - aliases: ["oyster"], - tags: [], - category: "Food & Drink", - description: "oyster", - unicode_version: "12.0", - }, - { - emoji: "🍦", - aliases: ["icecream"], - tags: [], - category: "Food & Drink", - description: "soft ice cream", - unicode_version: "6.0", - }, - { - emoji: "🍧", - aliases: ["shaved_ice"], - tags: [], - category: "Food & Drink", - description: "shaved ice", - unicode_version: "6.0", - }, - { - emoji: "🍨", - aliases: ["ice_cream"], - tags: [], - category: "Food & Drink", - description: "ice cream", - unicode_version: "6.0", - }, - { - emoji: "🍩", - aliases: ["doughnut"], - tags: [], - category: "Food & Drink", - description: "doughnut", - unicode_version: "6.0", - }, - { - emoji: "🍪", - aliases: ["cookie"], - tags: [], - category: "Food & Drink", - description: "cookie", - unicode_version: "6.0", - }, - { - emoji: "🎂", - aliases: ["birthday"], - tags: ["party"], - category: "Food & Drink", - description: "birthday cake", - unicode_version: "6.0", - }, - { - emoji: "🍰", - aliases: ["cake"], - tags: ["dessert"], - category: "Food & Drink", - description: "shortcake", - unicode_version: "6.0", - }, - { - emoji: "🧁", - aliases: ["cupcake"], - tags: [], - category: "Food & Drink", - description: "cupcake", - unicode_version: "11.0", - }, - { - emoji: "🥧", - aliases: ["pie"], - tags: [], - category: "Food & Drink", - description: "pie", - unicode_version: "11.0", - }, - { - emoji: "🍫", - aliases: ["chocolate_bar"], - tags: [], - category: "Food & Drink", - description: "chocolate bar", - unicode_version: "6.0", - }, - { - emoji: "🍬", - aliases: ["candy"], - tags: ["sweet"], - category: "Food & Drink", - description: "candy", - unicode_version: "6.0", - }, - { - emoji: "🍭", - aliases: ["lollipop"], - tags: [], - category: "Food & Drink", - description: "lollipop", - unicode_version: "6.0", - }, - { - emoji: "🍮", - aliases: ["custard"], - tags: [], - category: "Food & Drink", - description: "custard", - unicode_version: "6.0", - }, - { - emoji: "🍯", - aliases: ["honey_pot"], - tags: [], - category: "Food & Drink", - description: "honey pot", - unicode_version: "6.0", - }, - { - emoji: "🍼", - aliases: ["baby_bottle"], - tags: ["milk"], - category: "Food & Drink", - description: "baby bottle", - unicode_version: "6.0", - }, - { - emoji: "🥛", - aliases: ["milk_glass"], - tags: [], - category: "Food & Drink", - description: "glass of milk", - unicode_version: "9.0", - }, - { - emoji: "☕", - aliases: ["coffee"], - tags: ["cafe", "espresso"], - category: "Food & Drink", - description: "hot beverage", - unicode_version: "4.0", - }, - { - emoji: "🫖", - aliases: ["teapot"], - tags: [], - category: "Food & Drink", - description: "teapot", - unicode_version: "13.0", - }, - { - emoji: "🍵", - aliases: ["tea"], - tags: ["green", "breakfast"], - category: "Food & Drink", - description: "teacup without handle", - unicode_version: "6.0", - }, - { - emoji: "🍶", - aliases: ["sake"], - tags: [], - category: "Food & Drink", - description: "sake", - unicode_version: "6.0", - }, - { - emoji: "🍾", - aliases: ["champagne"], - tags: ["bottle", "bubbly", "celebration"], - category: "Food & Drink", - description: "bottle with popping cork", - unicode_version: "8.0", - }, - { - emoji: "🍷", - aliases: ["wine_glass"], - tags: [], - category: "Food & Drink", - description: "wine glass", - unicode_version: "6.0", - }, - { - emoji: "🍸", - aliases: ["cocktail"], - tags: ["drink"], - category: "Food & Drink", - description: "cocktail glass", - unicode_version: "6.0", - }, - { - emoji: "🍹", - aliases: ["tropical_drink"], - tags: ["summer", "vacation"], - category: "Food & Drink", - description: "tropical drink", - unicode_version: "6.0", - }, - { - emoji: "🍺", - aliases: ["beer"], - tags: ["drink"], - category: "Food & Drink", - description: "beer mug", - unicode_version: "6.0", - }, - { - emoji: "🍻", - aliases: ["beers"], - tags: ["drinks"], - category: "Food & Drink", - description: "clinking beer mugs", - unicode_version: "6.0", - }, - { - emoji: "🥂", - aliases: ["clinking_glasses"], - tags: ["cheers", "toast"], - category: "Food & Drink", - description: "clinking glasses", - unicode_version: "9.0", - }, - { - emoji: "🥃", - aliases: ["tumbler_glass"], - tags: ["whisky"], - category: "Food & Drink", - description: "tumbler glass", - unicode_version: "9.0", - }, - { - emoji: "🥤", - aliases: ["cup_with_straw"], - tags: [], - category: "Food & Drink", - description: "cup with straw", - unicode_version: "11.0", - }, - { - emoji: "🧋", - aliases: ["bubble_tea"], - tags: [], - category: "Food & Drink", - description: "bubble tea", - unicode_version: "13.0", - }, - { - emoji: "🧃", - aliases: ["beverage_box"], - tags: [], - category: "Food & Drink", - description: "beverage box", - unicode_version: "12.0", - }, - { - emoji: "🧉", - aliases: ["mate"], - tags: [], - category: "Food & Drink", - description: "mate", - unicode_version: "12.0", - }, - { - emoji: "🧊", - aliases: ["ice_cube"], - tags: [], - category: "Food & Drink", - description: "ice", - unicode_version: "12.0", - }, - { - emoji: "🥢", - aliases: ["chopsticks"], - tags: [], - category: "Food & Drink", - description: "chopsticks", - unicode_version: "11.0", - }, - { - emoji: "🍽️", - aliases: ["plate_with_cutlery"], - tags: ["dining", "dinner"], - category: "Food & Drink", - description: "fork and knife with plate", - unicode_version: "7.0", - }, - { - emoji: "🍴", - aliases: ["fork_and_knife"], - tags: ["cutlery"], - category: "Food & Drink", - description: "fork and knife", - unicode_version: "6.0", - }, - { - emoji: "🥄", - aliases: ["spoon"], - tags: [], - category: "Food & Drink", - description: "spoon", - unicode_version: "9.0", - }, - { - emoji: "🔪", - aliases: ["hocho", "knife"], - tags: ["cut", "chop"], - category: "Food & Drink", - description: "kitchen knife", - unicode_version: "6.0", - }, - { - emoji: "🏺", - aliases: ["amphora"], - tags: [], - category: "Food & Drink", - description: "amphora", - unicode_version: "8.0", - }, - { - emoji: "🌍", - aliases: ["earth_africa"], - tags: ["globe", "world", "international"], - category: "Travel & Places", - description: "globe showing Europe-Africa", - unicode_version: "6.0", - }, - { - emoji: "🌎", - aliases: ["earth_americas"], - tags: ["globe", "world", "international"], - category: "Travel & Places", - description: "globe showing Americas", - unicode_version: "6.0", - }, - { - emoji: "🌏", - aliases: ["earth_asia"], - tags: ["globe", "world", "international"], - category: "Travel & Places", - description: "globe showing Asia-Australia", - unicode_version: "6.0", - }, - { - emoji: "🌐", - aliases: ["globe_with_meridians"], - tags: ["world", "global", "international"], - category: "Travel & Places", - description: "globe with meridians", - unicode_version: "6.0", - }, - { - emoji: "🗺️", - aliases: ["world_map"], - tags: ["travel"], - category: "Travel & Places", - description: "world map", - unicode_version: "7.0", - }, - { - emoji: "🗾", - aliases: ["japan"], - tags: [], - category: "Travel & Places", - description: "map of Japan", - unicode_version: "6.0", - }, - { - emoji: "🧭", - aliases: ["compass"], - tags: [], - category: "Travel & Places", - description: "compass", - unicode_version: "11.0", - }, - { - emoji: "🏔️", - aliases: ["mountain_snow"], - tags: [], - category: "Travel & Places", - description: "snow-capped mountain", - unicode_version: "7.0", - }, - { - emoji: "⛰️", - aliases: ["mountain"], - tags: [], - category: "Travel & Places", - description: "mountain", - unicode_version: "5.2", - }, - { - emoji: "🌋", - aliases: ["volcano"], - tags: [], - category: "Travel & Places", - description: "volcano", - unicode_version: "6.0", - }, - { - emoji: "🗻", - aliases: ["mount_fuji"], - tags: [], - category: "Travel & Places", - description: "mount fuji", - unicode_version: "6.0", - }, - { - emoji: "🏕️", - aliases: ["camping"], - tags: [], - category: "Travel & Places", - description: "camping", - unicode_version: "7.0", - }, - { - emoji: "🏖️", - aliases: ["beach_umbrella"], - tags: [], - category: "Travel & Places", - description: "beach with umbrella", - unicode_version: "7.0", - }, - { - emoji: "🏜️", - aliases: ["desert"], - tags: [], - category: "Travel & Places", - description: "desert", - unicode_version: "7.0", - }, - { - emoji: "🏝️", - aliases: ["desert_island"], - tags: [], - category: "Travel & Places", - description: "desert island", - unicode_version: "7.0", - }, - { - emoji: "🏞️", - aliases: ["national_park"], - tags: [], - category: "Travel & Places", - description: "national park", - unicode_version: "7.0", - }, - { - emoji: "🏟️", - aliases: ["stadium"], - tags: [], - category: "Travel & Places", - description: "stadium", - unicode_version: "7.0", - }, - { - emoji: "🏛️", - aliases: ["classical_building"], - tags: [], - category: "Travel & Places", - description: "classical building", - unicode_version: "7.0", - }, - { - emoji: "🏗️", - aliases: ["building_construction"], - tags: [], - category: "Travel & Places", - description: "building construction", - unicode_version: "7.0", - }, - { - emoji: "🧱", - aliases: ["bricks"], - tags: [], - category: "Travel & Places", - description: "brick", - unicode_version: "11.0", - }, - { - emoji: "🪨", - aliases: ["rock"], - tags: [], - category: "Travel & Places", - description: "rock", - unicode_version: "13.0", - }, - { - emoji: "🪵", - aliases: ["wood"], - tags: [], - category: "Travel & Places", - description: "wood", - unicode_version: "13.0", - }, - { - emoji: "🛖", - aliases: ["hut"], - tags: [], - category: "Travel & Places", - description: "hut", - unicode_version: "13.0", - }, - { - emoji: "🏘️", - aliases: ["houses"], - tags: [], - category: "Travel & Places", - description: "houses", - unicode_version: "7.0", - }, - { - emoji: "🏚️", - aliases: ["derelict_house"], - tags: [], - category: "Travel & Places", - description: "derelict house", - unicode_version: "7.0", - }, - { - emoji: "🏠", - aliases: ["house"], - tags: [], - category: "Travel & Places", - description: "house", - unicode_version: "6.0", - }, - { - emoji: "🏡", - aliases: ["house_with_garden"], - tags: [], - category: "Travel & Places", - description: "house with garden", - unicode_version: "6.0", - }, - { - emoji: "🏢", - aliases: ["office"], - tags: [], - category: "Travel & Places", - description: "office building", - unicode_version: "6.0", - }, - { - emoji: "🏣", - aliases: ["post_office"], - tags: [], - category: "Travel & Places", - description: "Japanese post office", - unicode_version: "6.0", - }, - { - emoji: "🏤", - aliases: ["european_post_office"], - tags: [], - category: "Travel & Places", - description: "post office", - unicode_version: "6.0", - }, - { - emoji: "🏥", - aliases: ["hospital"], - tags: [], - category: "Travel & Places", - description: "hospital", - unicode_version: "6.0", - }, - { - emoji: "🏦", - aliases: ["bank"], - tags: [], - category: "Travel & Places", - description: "bank", - unicode_version: "6.0", - }, - { - emoji: "🏨", - aliases: ["hotel"], - tags: [], - category: "Travel & Places", - description: "hotel", - unicode_version: "6.0", - }, - { - emoji: "🏩", - aliases: ["love_hotel"], - tags: [], - category: "Travel & Places", - description: "love hotel", - unicode_version: "6.0", - }, - { - emoji: "🏪", - aliases: ["convenience_store"], - tags: [], - category: "Travel & Places", - description: "convenience store", - unicode_version: "6.0", - }, - { - emoji: "🏫", - aliases: ["school"], - tags: [], - category: "Travel & Places", - description: "school", - unicode_version: "6.0", - }, - { - emoji: "🏬", - aliases: ["department_store"], - tags: [], - category: "Travel & Places", - description: "department store", - unicode_version: "6.0", - }, - { - emoji: "🏭", - aliases: ["factory"], - tags: [], - category: "Travel & Places", - description: "factory", - unicode_version: "6.0", - }, - { - emoji: "🏯", - aliases: ["japanese_castle"], - tags: [], - category: "Travel & Places", - description: "Japanese castle", - unicode_version: "6.0", - }, - { - emoji: "🏰", - aliases: ["european_castle"], - tags: [], - category: "Travel & Places", - description: "castle", - unicode_version: "6.0", - }, - { - emoji: "💒", - aliases: ["wedding"], - tags: ["marriage"], - category: "Travel & Places", - description: "wedding", - unicode_version: "6.0", - }, - { - emoji: "🗼", - aliases: ["tokyo_tower"], - tags: [], - category: "Travel & Places", - description: "Tokyo tower", - unicode_version: "6.0", - }, - { - emoji: "🗽", - aliases: ["statue_of_liberty"], - tags: [], - category: "Travel & Places", - description: "Statue of Liberty", - unicode_version: "6.0", - }, - { - emoji: "⛪", - aliases: ["church"], - tags: [], - category: "Travel & Places", - description: "church", - unicode_version: "5.2", - }, - { - emoji: "🕌", - aliases: ["mosque"], - tags: [], - category: "Travel & Places", - description: "mosque", - unicode_version: "8.0", - }, - { - emoji: "🛕", - aliases: ["hindu_temple"], - tags: [], - category: "Travel & Places", - description: "hindu temple", - unicode_version: "12.0", - }, - { - emoji: "🕍", - aliases: ["synagogue"], - tags: [], - category: "Travel & Places", - description: "synagogue", - unicode_version: "8.0", - }, - { - emoji: "⛩️", - aliases: ["shinto_shrine"], - tags: [], - category: "Travel & Places", - description: "shinto shrine", - unicode_version: "5.2", - }, - { - emoji: "🕋", - aliases: ["kaaba"], - tags: [], - category: "Travel & Places", - description: "kaaba", - unicode_version: "8.0", - }, - { - emoji: "⛲", - aliases: ["fountain"], - tags: [], - category: "Travel & Places", - description: "fountain", - unicode_version: "5.2", - }, - { - emoji: "⛺", - aliases: ["tent"], - tags: ["camping"], - category: "Travel & Places", - description: "tent", - unicode_version: "5.2", - }, - { - emoji: "🌁", - aliases: ["foggy"], - tags: ["karl"], - category: "Travel & Places", - description: "foggy", - unicode_version: "6.0", - }, - { - emoji: "🌃", - aliases: ["night_with_stars"], - tags: [], - category: "Travel & Places", - description: "night with stars", - unicode_version: "6.0", - }, - { - emoji: "🏙️", - aliases: ["cityscape"], - tags: ["skyline"], - category: "Travel & Places", - description: "cityscape", - unicode_version: "7.0", - }, - { - emoji: "🌄", - aliases: ["sunrise_over_mountains"], - tags: [], - category: "Travel & Places", - description: "sunrise over mountains", - unicode_version: "6.0", - }, - { - emoji: "🌅", - aliases: ["sunrise"], - tags: [], - category: "Travel & Places", - description: "sunrise", - unicode_version: "6.0", - }, - { - emoji: "🌆", - aliases: ["city_sunset"], - tags: [], - category: "Travel & Places", - description: "cityscape at dusk", - unicode_version: "6.0", - }, - { - emoji: "🌇", - aliases: ["city_sunrise"], - tags: [], - category: "Travel & Places", - description: "sunset", - unicode_version: "6.0", - }, - { - emoji: "🌉", - aliases: ["bridge_at_night"], - tags: [], - category: "Travel & Places", - description: "bridge at night", - unicode_version: "6.0", - }, - { - emoji: "♨️", - aliases: ["hotsprings"], - tags: [], - category: "Travel & Places", - description: "hot springs", - unicode_version: "", - }, - { - emoji: "🎠", - aliases: ["carousel_horse"], - tags: [], - category: "Travel & Places", - description: "carousel horse", - unicode_version: "6.0", - }, - { - emoji: "🎡", - aliases: ["ferris_wheel"], - tags: [], - category: "Travel & Places", - description: "ferris wheel", - unicode_version: "6.0", - }, - { - emoji: "🎢", - aliases: ["roller_coaster"], - tags: [], - category: "Travel & Places", - description: "roller coaster", - unicode_version: "6.0", - }, - { - emoji: "💈", - aliases: ["barber"], - tags: [], - category: "Travel & Places", - description: "barber pole", - unicode_version: "6.0", - }, - { - emoji: "🎪", - aliases: ["circus_tent"], - tags: [], - category: "Travel & Places", - description: "circus tent", - unicode_version: "6.0", - }, - { - emoji: "🚂", - aliases: ["steam_locomotive"], - tags: ["train"], - category: "Travel & Places", - description: "locomotive", - unicode_version: "6.0", - }, - { - emoji: "🚃", - aliases: ["railway_car"], - tags: [], - category: "Travel & Places", - description: "railway car", - unicode_version: "6.0", - }, - { - emoji: "🚄", - aliases: ["bullettrain_side"], - tags: ["train"], - category: "Travel & Places", - description: "high-speed train", - unicode_version: "6.0", - }, - { - emoji: "🚅", - aliases: ["bullettrain_front"], - tags: ["train"], - category: "Travel & Places", - description: "bullet train", - unicode_version: "6.0", - }, - { - emoji: "🚆", - aliases: ["train2"], - tags: [], - category: "Travel & Places", - description: "train", - unicode_version: "6.0", - }, - { - emoji: "🚇", - aliases: ["metro"], - tags: [], - category: "Travel & Places", - description: "metro", - unicode_version: "6.0", - }, - { - emoji: "🚈", - aliases: ["light_rail"], - tags: [], - category: "Travel & Places", - description: "light rail", - unicode_version: "6.0", - }, - { - emoji: "🚉", - aliases: ["station"], - tags: [], - category: "Travel & Places", - description: "station", - unicode_version: "6.0", - }, - { - emoji: "🚊", - aliases: ["tram"], - tags: [], - category: "Travel & Places", - description: "tram", - unicode_version: "6.0", - }, - { - emoji: "🚝", - aliases: ["monorail"], - tags: [], - category: "Travel & Places", - description: "monorail", - unicode_version: "6.0", - }, - { - emoji: "🚞", - aliases: ["mountain_railway"], - tags: [], - category: "Travel & Places", - description: "mountain railway", - unicode_version: "6.0", - }, - { - emoji: "🚋", - aliases: ["train"], - tags: [], - category: "Travel & Places", - description: "tram car", - unicode_version: "6.0", - }, - { - emoji: "🚌", - aliases: ["bus"], - tags: [], - category: "Travel & Places", - description: "bus", - unicode_version: "6.0", - }, - { - emoji: "🚍", - aliases: ["oncoming_bus"], - tags: [], - category: "Travel & Places", - description: "oncoming bus", - unicode_version: "6.0", - }, - { - emoji: "🚎", - aliases: ["trolleybus"], - tags: [], - category: "Travel & Places", - description: "trolleybus", - unicode_version: "6.0", - }, - { - emoji: "🚐", - aliases: ["minibus"], - tags: [], - category: "Travel & Places", - description: "minibus", - unicode_version: "6.0", - }, - { - emoji: "🚑", - aliases: ["ambulance"], - tags: [], - category: "Travel & Places", - description: "ambulance", - unicode_version: "6.0", - }, - { - emoji: "🚒", - aliases: ["fire_engine"], - tags: [], - category: "Travel & Places", - description: "fire engine", - unicode_version: "6.0", - }, - { - emoji: "🚓", - aliases: ["police_car"], - tags: [], - category: "Travel & Places", - description: "police car", - unicode_version: "6.0", - }, - { - emoji: "🚔", - aliases: ["oncoming_police_car"], - tags: [], - category: "Travel & Places", - description: "oncoming police car", - unicode_version: "6.0", - }, - { - emoji: "🚕", - aliases: ["taxi"], - tags: [], - category: "Travel & Places", - description: "taxi", - unicode_version: "6.0", - }, - { - emoji: "🚖", - aliases: ["oncoming_taxi"], - tags: [], - category: "Travel & Places", - description: "oncoming taxi", - unicode_version: "6.0", - }, - { - emoji: "🚗", - aliases: ["car", "red_car"], - tags: [], - category: "Travel & Places", - description: "automobile", - unicode_version: "6.0", - }, - { - emoji: "🚘", - aliases: ["oncoming_automobile"], - tags: [], - category: "Travel & Places", - description: "oncoming automobile", - unicode_version: "6.0", - }, - { - emoji: "🚙", - aliases: ["blue_car"], - tags: [], - category: "Travel & Places", - description: "sport utility vehicle", - unicode_version: "6.0", - }, - { - emoji: "🛻", - aliases: ["pickup_truck"], - tags: [], - category: "Travel & Places", - description: "pickup truck", - unicode_version: "13.0", - }, - { - emoji: "🚚", - aliases: ["truck"], - tags: [], - category: "Travel & Places", - description: "delivery truck", - unicode_version: "6.0", - }, - { - emoji: "🚛", - aliases: ["articulated_lorry"], - tags: [], - category: "Travel & Places", - description: "articulated lorry", - unicode_version: "6.0", - }, - { - emoji: "🚜", - aliases: ["tractor"], - tags: [], - category: "Travel & Places", - description: "tractor", - unicode_version: "6.0", - }, - { - emoji: "🏎️", - aliases: ["racing_car"], - tags: [], - category: "Travel & Places", - description: "racing car", - unicode_version: "7.0", - }, - { - emoji: "🏍️", - aliases: ["motorcycle"], - tags: [], - category: "Travel & Places", - description: "motorcycle", - unicode_version: "7.0", - }, - { - emoji: "🛵", - aliases: ["motor_scooter"], - tags: [], - category: "Travel & Places", - description: "motor scooter", - unicode_version: "9.0", - }, - { - emoji: "🦽", - aliases: ["manual_wheelchair"], - tags: [], - category: "Travel & Places", - description: "manual wheelchair", - unicode_version: "12.0", - }, - { - emoji: "🦼", - aliases: ["motorized_wheelchair"], - tags: [], - category: "Travel & Places", - description: "motorized wheelchair", - unicode_version: "12.0", - }, - { - emoji: "🛺", - aliases: ["auto_rickshaw"], - tags: [], - category: "Travel & Places", - description: "auto rickshaw", - unicode_version: "12.0", - }, - { - emoji: "🚲", - aliases: ["bike"], - tags: ["bicycle"], - category: "Travel & Places", - description: "bicycle", - unicode_version: "6.0", - }, - { - emoji: "🛴", - aliases: ["kick_scooter"], - tags: [], - category: "Travel & Places", - description: "kick scooter", - unicode_version: "9.0", - }, - { - emoji: "🛹", - aliases: ["skateboard"], - tags: [], - category: "Travel & Places", - description: "skateboard", - unicode_version: "11.0", - }, - { - emoji: "🛼", - aliases: ["roller_skate"], - tags: [], - category: "Travel & Places", - description: "roller skate", - unicode_version: "13.0", - }, - { - emoji: "🚏", - aliases: ["busstop"], - tags: [], - category: "Travel & Places", - description: "bus stop", - unicode_version: "6.0", - }, - { - emoji: "🛣️", - aliases: ["motorway"], - tags: [], - category: "Travel & Places", - description: "motorway", - unicode_version: "7.0", - }, - { - emoji: "🛤️", - aliases: ["railway_track"], - tags: [], - category: "Travel & Places", - description: "railway track", - unicode_version: "7.0", - }, - { - emoji: "🛢️", - aliases: ["oil_drum"], - tags: [], - category: "Travel & Places", - description: "oil drum", - unicode_version: "7.0", - }, - { - emoji: "⛽", - aliases: ["fuelpump"], - tags: [], - category: "Travel & Places", - description: "fuel pump", - unicode_version: "5.2", - }, - { - emoji: "🚨", - aliases: ["rotating_light"], - tags: ["911", "emergency"], - category: "Travel & Places", - description: "police car light", - unicode_version: "6.0", - }, - { - emoji: "🚥", - aliases: ["traffic_light"], - tags: [], - category: "Travel & Places", - description: "horizontal traffic light", - unicode_version: "6.0", - }, - { - emoji: "🚦", - aliases: ["vertical_traffic_light"], - tags: ["semaphore"], - category: "Travel & Places", - description: "vertical traffic light", - unicode_version: "6.0", - }, - { - emoji: "🛑", - aliases: ["stop_sign"], - tags: [], - category: "Travel & Places", - description: "stop sign", - unicode_version: "9.0", - }, - { - emoji: "🚧", - aliases: ["construction"], - tags: ["wip"], - category: "Travel & Places", - description: "construction", - unicode_version: "6.0", - }, - { - emoji: "⚓", - aliases: ["anchor"], - tags: ["ship"], - category: "Travel & Places", - description: "anchor", - unicode_version: "4.1", - }, - { - emoji: "⛵", - aliases: ["boat", "sailboat"], - tags: [], - category: "Travel & Places", - description: "sailboat", - unicode_version: "5.2", - }, - { - emoji: "🛶", - aliases: ["canoe"], - tags: [], - category: "Travel & Places", - description: "canoe", - unicode_version: "9.0", - }, - { - emoji: "🚤", - aliases: ["speedboat"], - tags: ["ship"], - category: "Travel & Places", - description: "speedboat", - unicode_version: "6.0", - }, - { - emoji: "🛳️", - aliases: ["passenger_ship"], - tags: ["cruise"], - category: "Travel & Places", - description: "passenger ship", - unicode_version: "7.0", - }, - { - emoji: "⛴️", - aliases: ["ferry"], - tags: [], - category: "Travel & Places", - description: "ferry", - unicode_version: "5.2", - }, - { - emoji: "🛥️", - aliases: ["motor_boat"], - tags: [], - category: "Travel & Places", - description: "motor boat", - unicode_version: "7.0", - }, - { - emoji: "🚢", - aliases: ["ship"], - tags: [], - category: "Travel & Places", - description: "ship", - unicode_version: "6.0", - }, - { - emoji: "✈️", - aliases: ["airplane"], - tags: ["flight"], - category: "Travel & Places", - description: "airplane", - unicode_version: "", - }, - { - emoji: "🛩️", - aliases: ["small_airplane"], - tags: ["flight"], - category: "Travel & Places", - description: "small airplane", - unicode_version: "7.0", - }, - { - emoji: "🛫", - aliases: ["flight_departure"], - tags: [], - category: "Travel & Places", - description: "airplane departure", - unicode_version: "7.0", - }, - { - emoji: "🛬", - aliases: ["flight_arrival"], - tags: [], - category: "Travel & Places", - description: "airplane arrival", - unicode_version: "7.0", - }, - { - emoji: "🪂", - aliases: ["parachute"], - tags: [], - category: "Travel & Places", - description: "parachute", - unicode_version: "12.0", - }, - { - emoji: "💺", - aliases: ["seat"], - tags: [], - category: "Travel & Places", - description: "seat", - unicode_version: "6.0", - }, - { - emoji: "🚁", - aliases: ["helicopter"], - tags: [], - category: "Travel & Places", - description: "helicopter", - unicode_version: "6.0", - }, - { - emoji: "🚟", - aliases: ["suspension_railway"], - tags: [], - category: "Travel & Places", - description: "suspension railway", - unicode_version: "6.0", - }, - { - emoji: "🚠", - aliases: ["mountain_cableway"], - tags: [], - category: "Travel & Places", - description: "mountain cableway", - unicode_version: "6.0", - }, - { - emoji: "🚡", - aliases: ["aerial_tramway"], - tags: [], - category: "Travel & Places", - description: "aerial tramway", - unicode_version: "6.0", - }, - { - emoji: "🛰️", - aliases: ["artificial_satellite"], - tags: ["orbit", "space"], - category: "Travel & Places", - description: "satellite", - unicode_version: "7.0", - }, - { - emoji: "🚀", - aliases: ["rocket"], - tags: ["ship", "launch"], - category: "Travel & Places", - description: "rocket", - unicode_version: "6.0", - }, - { - emoji: "🛸", - aliases: ["flying_saucer"], - tags: ["ufo"], - category: "Travel & Places", - description: "flying saucer", - unicode_version: "11.0", - }, - { - emoji: "🛎️", - aliases: ["bellhop_bell"], - tags: [], - category: "Travel & Places", - description: "bellhop bell", - unicode_version: "7.0", - }, - { - emoji: "🧳", - aliases: ["luggage"], - tags: [], - category: "Travel & Places", - description: "luggage", - unicode_version: "11.0", - }, - { - emoji: "⌛", - aliases: ["hourglass"], - tags: ["time"], - category: "Travel & Places", - description: "hourglass done", - unicode_version: "", - }, - { - emoji: "⏳", - aliases: ["hourglass_flowing_sand"], - tags: ["time"], - category: "Travel & Places", - description: "hourglass not done", - unicode_version: "6.0", - }, - { - emoji: "⌚", - aliases: ["watch"], - tags: ["time"], - category: "Travel & Places", - description: "watch", - unicode_version: "", - }, - { - emoji: "⏰", - aliases: ["alarm_clock"], - tags: ["morning"], - category: "Travel & Places", - description: "alarm clock", - unicode_version: "6.0", - }, - { - emoji: "⏱️", - aliases: ["stopwatch"], - tags: [], - category: "Travel & Places", - description: "stopwatch", - unicode_version: "6.0", - }, - { - emoji: "⏲️", - aliases: ["timer_clock"], - tags: [], - category: "Travel & Places", - description: "timer clock", - unicode_version: "6.0", - }, - { - emoji: "🕰️", - aliases: ["mantelpiece_clock"], - tags: [], - category: "Travel & Places", - description: "mantelpiece clock", - unicode_version: "7.0", - }, - { - emoji: "🕛", - aliases: ["clock12"], - tags: [], - category: "Travel & Places", - description: "twelve o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕧", - aliases: ["clock1230"], - tags: [], - category: "Travel & Places", - description: "twelve-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕐", - aliases: ["clock1"], - tags: [], - category: "Travel & Places", - description: "one o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕜", - aliases: ["clock130"], - tags: [], - category: "Travel & Places", - description: "one-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕑", - aliases: ["clock2"], - tags: [], - category: "Travel & Places", - description: "two o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕝", - aliases: ["clock230"], - tags: [], - category: "Travel & Places", - description: "two-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕒", - aliases: ["clock3"], - tags: [], - category: "Travel & Places", - description: "three o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕞", - aliases: ["clock330"], - tags: [], - category: "Travel & Places", - description: "three-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕓", - aliases: ["clock4"], - tags: [], - category: "Travel & Places", - description: "four o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕟", - aliases: ["clock430"], - tags: [], - category: "Travel & Places", - description: "four-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕔", - aliases: ["clock5"], - tags: [], - category: "Travel & Places", - description: "five o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕠", - aliases: ["clock530"], - tags: [], - category: "Travel & Places", - description: "five-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕕", - aliases: ["clock6"], - tags: [], - category: "Travel & Places", - description: "six o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕡", - aliases: ["clock630"], - tags: [], - category: "Travel & Places", - description: "six-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕖", - aliases: ["clock7"], - tags: [], - category: "Travel & Places", - description: "seven o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕢", - aliases: ["clock730"], - tags: [], - category: "Travel & Places", - description: "seven-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕗", - aliases: ["clock8"], - tags: [], - category: "Travel & Places", - description: "eight o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕣", - aliases: ["clock830"], - tags: [], - category: "Travel & Places", - description: "eight-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕘", - aliases: ["clock9"], - tags: [], - category: "Travel & Places", - description: "nine o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕤", - aliases: ["clock930"], - tags: [], - category: "Travel & Places", - description: "nine-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕙", - aliases: ["clock10"], - tags: [], - category: "Travel & Places", - description: "ten o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕥", - aliases: ["clock1030"], - tags: [], - category: "Travel & Places", - description: "ten-thirty", - unicode_version: "6.0", - }, - { - emoji: "🕚", - aliases: ["clock11"], - tags: [], - category: "Travel & Places", - description: "eleven o’clock", - unicode_version: "6.0", - }, - { - emoji: "🕦", - aliases: ["clock1130"], - tags: [], - category: "Travel & Places", - description: "eleven-thirty", - unicode_version: "6.0", - }, - { - emoji: "🌑", - aliases: ["new_moon"], - tags: [], - category: "Travel & Places", - description: "new moon", - unicode_version: "6.0", - }, - { - emoji: "🌒", - aliases: ["waxing_crescent_moon"], - tags: [], - category: "Travel & Places", - description: "waxing crescent moon", - unicode_version: "6.0", - }, - { - emoji: "🌓", - aliases: ["first_quarter_moon"], - tags: [], - category: "Travel & Places", - description: "first quarter moon", - unicode_version: "6.0", - }, - { - emoji: "🌔", - aliases: ["moon", "waxing_gibbous_moon"], - tags: [], - category: "Travel & Places", - description: "waxing gibbous moon", - unicode_version: "6.0", - }, - { - emoji: "🌕", - aliases: ["full_moon"], - tags: [], - category: "Travel & Places", - description: "full moon", - unicode_version: "6.0", - }, - { - emoji: "🌖", - aliases: ["waning_gibbous_moon"], - tags: [], - category: "Travel & Places", - description: "waning gibbous moon", - unicode_version: "6.0", - }, - { - emoji: "🌗", - aliases: ["last_quarter_moon"], - tags: [], - category: "Travel & Places", - description: "last quarter moon", - unicode_version: "6.0", - }, - { - emoji: "🌘", - aliases: ["waning_crescent_moon"], - tags: [], - category: "Travel & Places", - description: "waning crescent moon", - unicode_version: "6.0", - }, - { - emoji: "🌙", - aliases: ["crescent_moon"], - tags: ["night"], - category: "Travel & Places", - description: "crescent moon", - unicode_version: "6.0", - }, - { - emoji: "🌚", - aliases: ["new_moon_with_face"], - tags: [], - category: "Travel & Places", - description: "new moon face", - unicode_version: "6.0", - }, - { - emoji: "🌛", - aliases: ["first_quarter_moon_with_face"], - tags: [], - category: "Travel & Places", - description: "first quarter moon face", - unicode_version: "6.0", - }, - { - emoji: "🌜", - aliases: ["last_quarter_moon_with_face"], - tags: [], - category: "Travel & Places", - description: "last quarter moon face", - unicode_version: "6.0", - }, - { - emoji: "🌡️", - aliases: ["thermometer"], - tags: [], - category: "Travel & Places", - description: "thermometer", - unicode_version: "7.0", - }, - { - emoji: "☀️", - aliases: ["sunny"], - tags: ["weather"], - category: "Travel & Places", - description: "sun", - unicode_version: "", - }, - { - emoji: "🌝", - aliases: ["full_moon_with_face"], - tags: [], - category: "Travel & Places", - description: "full moon face", - unicode_version: "6.0", - }, - { - emoji: "🌞", - aliases: ["sun_with_face"], - tags: ["summer"], - category: "Travel & Places", - description: "sun with face", - unicode_version: "6.0", - }, - { - emoji: "🪐", - aliases: ["ringed_planet"], - tags: [], - category: "Travel & Places", - description: "ringed planet", - unicode_version: "12.0", - }, - { - emoji: "⭐", - aliases: ["star"], - tags: [], - category: "Travel & Places", - description: "star", - unicode_version: "5.1", - }, - { - emoji: "🌟", - aliases: ["star2"], - tags: [], - category: "Travel & Places", - description: "glowing star", - unicode_version: "6.0", - }, - { - emoji: "🌠", - aliases: ["stars"], - tags: [], - category: "Travel & Places", - description: "shooting star", - unicode_version: "6.0", - }, - { - emoji: "🌌", - aliases: ["milky_way"], - tags: [], - category: "Travel & Places", - description: "milky way", - unicode_version: "6.0", - }, - { - emoji: "☁️", - aliases: ["cloud"], - tags: [], - category: "Travel & Places", - description: "cloud", - unicode_version: "", - }, - { - emoji: "⛅", - aliases: ["partly_sunny"], - tags: ["weather", "cloud"], - category: "Travel & Places", - description: "sun behind cloud", - unicode_version: "5.2", - }, - { - emoji: "⛈️", - aliases: ["cloud_with_lightning_and_rain"], - tags: [], - category: "Travel & Places", - description: "cloud with lightning and rain", - unicode_version: "5.2", - }, - { - emoji: "🌤️", - aliases: ["sun_behind_small_cloud"], - tags: [], - category: "Travel & Places", - description: "sun behind small cloud", - unicode_version: "7.0", - }, - { - emoji: "🌥️", - aliases: ["sun_behind_large_cloud"], - tags: [], - category: "Travel & Places", - description: "sun behind large cloud", - unicode_version: "7.0", - }, - { - emoji: "🌦️", - aliases: ["sun_behind_rain_cloud"], - tags: [], - category: "Travel & Places", - description: "sun behind rain cloud", - unicode_version: "7.0", - }, - { - emoji: "🌧️", - aliases: ["cloud_with_rain"], - tags: [], - category: "Travel & Places", - description: "cloud with rain", - unicode_version: "7.0", - }, - { - emoji: "🌨️", - aliases: ["cloud_with_snow"], - tags: [], - category: "Travel & Places", - description: "cloud with snow", - unicode_version: "7.0", - }, - { - emoji: "🌩️", - aliases: ["cloud_with_lightning"], - tags: [], - category: "Travel & Places", - description: "cloud with lightning", - unicode_version: "7.0", - }, - { - emoji: "🌪️", - aliases: ["tornado"], - tags: [], - category: "Travel & Places", - description: "tornado", - unicode_version: "7.0", - }, - { - emoji: "🌫️", - aliases: ["fog"], - tags: [], - category: "Travel & Places", - description: "fog", - unicode_version: "7.0", - }, - { - emoji: "🌬️", - aliases: ["wind_face"], - tags: [], - category: "Travel & Places", - description: "wind face", - unicode_version: "7.0", - }, - { - emoji: "🌀", - aliases: ["cyclone"], - tags: ["swirl"], - category: "Travel & Places", - description: "cyclone", - unicode_version: "6.0", - }, - { - emoji: "🌈", - aliases: ["rainbow"], - tags: [], - category: "Travel & Places", - description: "rainbow", - unicode_version: "6.0", - }, - { - emoji: "🌂", - aliases: ["closed_umbrella"], - tags: ["weather", "rain"], - category: "Travel & Places", - description: "closed umbrella", - unicode_version: "6.0", - }, - { - emoji: "☂️", - aliases: ["open_umbrella"], - tags: [], - category: "Travel & Places", - description: "umbrella", - unicode_version: "", - }, - { - emoji: "☔", - aliases: ["umbrella"], - tags: ["rain", "weather"], - category: "Travel & Places", - description: "umbrella with rain drops", - unicode_version: "4.0", - }, - { - emoji: "⛱️", - aliases: ["parasol_on_ground"], - tags: ["beach_umbrella"], - category: "Travel & Places", - description: "umbrella on ground", - unicode_version: "5.2", - }, - { - emoji: "⚡", - aliases: ["zap"], - tags: ["lightning", "thunder"], - category: "Travel & Places", - description: "high voltage", - unicode_version: "4.0", - }, - { - emoji: "❄️", - aliases: ["snowflake"], - tags: ["winter", "cold", "weather"], - category: "Travel & Places", - description: "snowflake", - unicode_version: "", - }, - { - emoji: "☃️", - aliases: ["snowman_with_snow"], - tags: ["winter", "christmas"], - category: "Travel & Places", - description: "snowman", - unicode_version: "", - }, - { - emoji: "⛄", - aliases: ["snowman"], - tags: ["winter"], - category: "Travel & Places", - description: "snowman without snow", - unicode_version: "5.2", - }, - { - emoji: "☄️", - aliases: ["comet"], - tags: [], - category: "Travel & Places", - description: "comet", - unicode_version: "", - }, - { - emoji: "🔥", - aliases: ["fire"], - tags: ["burn"], - category: "Travel & Places", - description: "fire", - unicode_version: "6.0", - }, - { - emoji: "💧", - aliases: ["droplet"], - tags: ["water"], - category: "Travel & Places", - description: "droplet", - unicode_version: "6.0", - }, - { - emoji: "🌊", - aliases: ["ocean"], - tags: ["sea"], - category: "Travel & Places", - description: "water wave", - unicode_version: "6.0", - }, - { - emoji: "🎃", - aliases: ["jack_o_lantern"], - tags: ["halloween"], - category: "Activities", - description: "jack-o-lantern", - unicode_version: "6.0", - }, - { - emoji: "🎄", - aliases: ["christmas_tree"], - tags: [], - category: "Activities", - description: "Christmas tree", - unicode_version: "6.0", - }, - { - emoji: "🎆", - aliases: ["fireworks"], - tags: ["festival", "celebration"], - category: "Activities", - description: "fireworks", - unicode_version: "6.0", - }, - { - emoji: "🎇", - aliases: ["sparkler"], - tags: [], - category: "Activities", - description: "sparkler", - unicode_version: "6.0", - }, - { - emoji: "🧨", - aliases: ["firecracker"], - tags: [], - category: "Activities", - description: "firecracker", - unicode_version: "11.0", - }, - { - emoji: "✨", - aliases: ["sparkles"], - tags: ["shiny"], - category: "Activities", - description: "sparkles", - unicode_version: "6.0", - }, - { - emoji: "🎈", - aliases: ["balloon"], - tags: ["party", "birthday"], - category: "Activities", - description: "balloon", - unicode_version: "6.0", - }, - { - emoji: "🎉", - aliases: ["tada"], - tags: ["hooray", "party"], - category: "Activities", - description: "party popper", - unicode_version: "6.0", - }, - { - emoji: "🎊", - aliases: ["confetti_ball"], - tags: [], - category: "Activities", - description: "confetti ball", - unicode_version: "6.0", - }, - { - emoji: "🎋", - aliases: ["tanabata_tree"], - tags: [], - category: "Activities", - description: "tanabata tree", - unicode_version: "6.0", - }, - { - emoji: "🎍", - aliases: ["bamboo"], - tags: [], - category: "Activities", - description: "pine decoration", - unicode_version: "6.0", - }, - { - emoji: "🎎", - aliases: ["dolls"], - tags: [], - category: "Activities", - description: "Japanese dolls", - unicode_version: "6.0", - }, - { - emoji: "🎏", - aliases: ["flags"], - tags: [], - category: "Activities", - description: "carp streamer", - unicode_version: "6.0", - }, - { - emoji: "🎐", - aliases: ["wind_chime"], - tags: [], - category: "Activities", - description: "wind chime", - unicode_version: "6.0", - }, - { - emoji: "🎑", - aliases: ["rice_scene"], - tags: [], - category: "Activities", - description: "moon viewing ceremony", - unicode_version: "6.0", - }, - { - emoji: "🧧", - aliases: ["red_envelope"], - tags: [], - category: "Activities", - description: "red envelope", - unicode_version: "11.0", - }, - { - emoji: "🎀", - aliases: ["ribbon"], - tags: [], - category: "Activities", - description: "ribbon", - unicode_version: "6.0", - }, - { - emoji: "🎁", - aliases: ["gift"], - tags: ["present", "birthday", "christmas"], - category: "Activities", - description: "wrapped gift", - unicode_version: "6.0", - }, - { - emoji: "🎗️", - aliases: ["reminder_ribbon"], - tags: [], - category: "Activities", - description: "reminder ribbon", - unicode_version: "7.0", - }, - { - emoji: "🎟️", - aliases: ["tickets"], - tags: [], - category: "Activities", - description: "admission tickets", - unicode_version: "7.0", - }, - { - emoji: "🎫", - aliases: ["ticket"], - tags: [], - category: "Activities", - description: "ticket", - unicode_version: "6.0", - }, - { - emoji: "🎖️", - aliases: ["medal_military"], - tags: [], - category: "Activities", - description: "military medal", - unicode_version: "7.0", - }, - { - emoji: "🏆", - aliases: ["trophy"], - tags: ["award", "contest", "winner"], - category: "Activities", - description: "trophy", - unicode_version: "6.0", - }, - { - emoji: "🏅", - aliases: ["medal_sports"], - tags: ["gold", "winner"], - category: "Activities", - description: "sports medal", - unicode_version: "7.0", - }, - { - emoji: "🥇", - aliases: ["1st_place_medal"], - tags: ["gold"], - category: "Activities", - description: "1st place medal", - unicode_version: "9.0", - }, - { - emoji: "🥈", - aliases: ["2nd_place_medal"], - tags: ["silver"], - category: "Activities", - description: "2nd place medal", - unicode_version: "9.0", - }, - { - emoji: "🥉", - aliases: ["3rd_place_medal"], - tags: ["bronze"], - category: "Activities", - description: "3rd place medal", - unicode_version: "9.0", - }, - { - emoji: "⚽", - aliases: ["soccer"], - tags: ["sports"], - category: "Activities", - description: "soccer ball", - unicode_version: "5.2", - }, - { - emoji: "⚾", - aliases: ["baseball"], - tags: ["sports"], - category: "Activities", - description: "baseball", - unicode_version: "5.2", - }, - { - emoji: "🥎", - aliases: ["softball"], - tags: [], - category: "Activities", - description: "softball", - unicode_version: "11.0", - }, - { - emoji: "🏀", - aliases: ["basketball"], - tags: ["sports"], - category: "Activities", - description: "basketball", - unicode_version: "6.0", - }, - { - emoji: "🏐", - aliases: ["volleyball"], - tags: [], - category: "Activities", - description: "volleyball", - unicode_version: "8.0", - }, - { - emoji: "🏈", - aliases: ["football"], - tags: ["sports"], - category: "Activities", - description: "american football", - unicode_version: "6.0", - }, - { - emoji: "🏉", - aliases: ["rugby_football"], - tags: [], - category: "Activities", - description: "rugby football", - unicode_version: "6.0", - }, - { - emoji: "🎾", - aliases: ["tennis"], - tags: ["sports"], - category: "Activities", - description: "tennis", - unicode_version: "6.0", - }, - { - emoji: "🥏", - aliases: ["flying_disc"], - tags: [], - category: "Activities", - description: "flying disc", - unicode_version: "11.0", - }, - { - emoji: "🎳", - aliases: ["bowling"], - tags: [], - category: "Activities", - description: "bowling", - unicode_version: "6.0", - }, - { - emoji: "🏏", - aliases: ["cricket_game"], - tags: [], - category: "Activities", - description: "cricket game", - unicode_version: "8.0", - }, - { - emoji: "🏑", - aliases: ["field_hockey"], - tags: [], - category: "Activities", - description: "field hockey", - unicode_version: "8.0", - }, - { - emoji: "🏒", - aliases: ["ice_hockey"], - tags: [], - category: "Activities", - description: "ice hockey", - unicode_version: "8.0", - }, - { - emoji: "🥍", - aliases: ["lacrosse"], - tags: [], - category: "Activities", - description: "lacrosse", - unicode_version: "11.0", - }, - { - emoji: "🏓", - aliases: ["ping_pong"], - tags: [], - category: "Activities", - description: "ping pong", - unicode_version: "8.0", - }, - { - emoji: "🏸", - aliases: ["badminton"], - tags: [], - category: "Activities", - description: "badminton", - unicode_version: "8.0", - }, - { - emoji: "🥊", - aliases: ["boxing_glove"], - tags: [], - category: "Activities", - description: "boxing glove", - unicode_version: "9.0", - }, - { - emoji: "🥋", - aliases: ["martial_arts_uniform"], - tags: [], - category: "Activities", - description: "martial arts uniform", - unicode_version: "9.0", - }, - { - emoji: "🥅", - aliases: ["goal_net"], - tags: [], - category: "Activities", - description: "goal net", - unicode_version: "9.0", - }, - { - emoji: "⛳", - aliases: ["golf"], - tags: [], - category: "Activities", - description: "flag in hole", - unicode_version: "5.2", - }, - { - emoji: "⛸️", - aliases: ["ice_skate"], - tags: ["skating"], - category: "Activities", - description: "ice skate", - unicode_version: "5.2", - }, - { - emoji: "🎣", - aliases: ["fishing_pole_and_fish"], - tags: [], - category: "Activities", - description: "fishing pole", - unicode_version: "6.0", - }, - { - emoji: "🤿", - aliases: ["diving_mask"], - tags: [], - category: "Activities", - description: "diving mask", - unicode_version: "12.0", - }, - { - emoji: "🎽", - aliases: ["running_shirt_with_sash"], - tags: ["marathon"], - category: "Activities", - description: "running shirt", - unicode_version: "6.0", - }, - { - emoji: "🎿", - aliases: ["ski"], - tags: [], - category: "Activities", - description: "skis", - unicode_version: "6.0", - }, - { - emoji: "🛷", - aliases: ["sled"], - tags: [], - category: "Activities", - description: "sled", - unicode_version: "11.0", - }, - { - emoji: "🥌", - aliases: ["curling_stone"], - tags: [], - category: "Activities", - description: "curling stone", - unicode_version: "11.0", - }, - { - emoji: "🎯", - aliases: ["dart"], - tags: ["target"], - category: "Activities", - description: "bullseye", - unicode_version: "6.0", - }, - { - emoji: "🪀", - aliases: ["yo_yo"], - tags: [], - category: "Activities", - description: "yo-yo", - unicode_version: "12.0", - }, - { - emoji: "🪁", - aliases: ["kite"], - tags: [], - category: "Activities", - description: "kite", - unicode_version: "12.0", - }, - { - emoji: "🎱", - aliases: ["8ball"], - tags: ["pool", "billiards"], - category: "Activities", - description: "pool 8 ball", - unicode_version: "6.0", - }, - { - emoji: "🔮", - aliases: ["crystal_ball"], - tags: ["fortune"], - category: "Activities", - description: "crystal ball", - unicode_version: "6.0", - }, - { - emoji: "🪄", - aliases: ["magic_wand"], - tags: [], - category: "Activities", - description: "magic wand", - unicode_version: "13.0", - }, - { - emoji: "🧿", - aliases: ["nazar_amulet"], - tags: [], - category: "Activities", - description: "nazar amulet", - unicode_version: "11.0", - }, - { - emoji: "🎮", - aliases: ["video_game"], - tags: ["play", "controller", "console"], - category: "Activities", - description: "video game", - unicode_version: "6.0", - }, - { - emoji: "🕹️", - aliases: ["joystick"], - tags: [], - category: "Activities", - description: "joystick", - unicode_version: "7.0", - }, - { - emoji: "🎰", - aliases: ["slot_machine"], - tags: [], - category: "Activities", - description: "slot machine", - unicode_version: "6.0", - }, - { - emoji: "🎲", - aliases: ["game_die"], - tags: ["dice", "gambling"], - category: "Activities", - description: "game die", - unicode_version: "6.0", - }, - { - emoji: "🧩", - aliases: ["jigsaw"], - tags: [], - category: "Activities", - description: "puzzle piece", - unicode_version: "11.0", - }, - { - emoji: "🧸", - aliases: ["teddy_bear"], - tags: [], - category: "Activities", - description: "teddy bear", - unicode_version: "11.0", - }, - { - emoji: "🪅", - aliases: ["pinata"], - tags: [], - category: "Activities", - description: "piñata", - unicode_version: "13.0", - }, - { - emoji: "🪆", - aliases: ["nesting_dolls"], - tags: [], - category: "Activities", - description: "nesting dolls", - unicode_version: "13.0", - }, - { - emoji: "♠️", - aliases: ["spades"], - tags: [], - category: "Activities", - description: "spade suit", - unicode_version: "", - }, - { - emoji: "♥️", - aliases: ["hearts"], - tags: [], - category: "Activities", - description: "heart suit", - unicode_version: "", - }, - { - emoji: "♦️", - aliases: ["diamonds"], - tags: [], - category: "Activities", - description: "diamond suit", - unicode_version: "", - }, - { - emoji: "♣️", - aliases: ["clubs"], - tags: [], - category: "Activities", - description: "club suit", - unicode_version: "", - }, - { - emoji: "♟️", - aliases: ["chess_pawn"], - tags: [], - category: "Activities", - description: "chess pawn", - unicode_version: "11.0", - }, - { - emoji: "🃏", - aliases: ["black_joker"], - tags: [], - category: "Activities", - description: "joker", - unicode_version: "6.0", - }, - { - emoji: "🀄", - aliases: ["mahjong"], - tags: [], - category: "Activities", - description: "mahjong red dragon", - unicode_version: "", - }, - { - emoji: "🎴", - aliases: ["flower_playing_cards"], - tags: [], - category: "Activities", - description: "flower playing cards", - unicode_version: "6.0", - }, - { - emoji: "🎭", - aliases: ["performing_arts"], - tags: ["theater", "drama"], - category: "Activities", - description: "performing arts", - unicode_version: "6.0", - }, - { - emoji: "🖼️", - aliases: ["framed_picture"], - tags: [], - category: "Activities", - description: "framed picture", - unicode_version: "7.0", - }, - { - emoji: "🎨", - aliases: ["art"], - tags: ["design", "paint"], - category: "Activities", - description: "artist palette", - unicode_version: "6.0", - }, - { - emoji: "🧵", - aliases: ["thread"], - tags: [], - category: "Activities", - description: "thread", - unicode_version: "11.0", - }, - { - emoji: "🪡", - aliases: ["sewing_needle"], - tags: [], - category: "Activities", - description: "sewing needle", - unicode_version: "13.0", - }, - { - emoji: "🧶", - aliases: ["yarn"], - tags: [], - category: "Activities", - description: "yarn", - unicode_version: "11.0", - }, - { - emoji: "🪢", - aliases: ["knot"], - tags: [], - category: "Activities", - description: "knot", - unicode_version: "13.0", - }, - { - emoji: "👓", - aliases: ["eyeglasses"], - tags: ["glasses"], - category: "Objects", - description: "glasses", - unicode_version: "6.0", - }, - { - emoji: "🕶️", - aliases: ["dark_sunglasses"], - tags: [], - category: "Objects", - description: "sunglasses", - unicode_version: "7.0", - }, - { - emoji: "🥽", - aliases: ["goggles"], - tags: [], - category: "Objects", - description: "goggles", - unicode_version: "11.0", - }, - { - emoji: "🥼", - aliases: ["lab_coat"], - tags: [], - category: "Objects", - description: "lab coat", - unicode_version: "11.0", - }, - { - emoji: "🦺", - aliases: ["safety_vest"], - tags: [], - category: "Objects", - description: "safety vest", - unicode_version: "12.0", - }, - { - emoji: "👔", - aliases: ["necktie"], - tags: ["shirt", "formal"], - category: "Objects", - description: "necktie", - unicode_version: "6.0", - }, - { - emoji: "👕", - aliases: ["shirt", "tshirt"], - tags: [], - category: "Objects", - description: "t-shirt", - unicode_version: "6.0", - }, - { - emoji: "👖", - aliases: ["jeans"], - tags: ["pants"], - category: "Objects", - description: "jeans", - unicode_version: "6.0", - }, - { - emoji: "🧣", - aliases: ["scarf"], - tags: [], - category: "Objects", - description: "scarf", - unicode_version: "11.0", - }, - { - emoji: "🧤", - aliases: ["gloves"], - tags: [], - category: "Objects", - description: "gloves", - unicode_version: "11.0", - }, - { - emoji: "🧥", - aliases: ["coat"], - tags: [], - category: "Objects", - description: "coat", - unicode_version: "11.0", - }, - { - emoji: "🧦", - aliases: ["socks"], - tags: [], - category: "Objects", - description: "socks", - unicode_version: "11.0", - }, - { - emoji: "👗", - aliases: ["dress"], - tags: [], - category: "Objects", - description: "dress", - unicode_version: "6.0", - }, - { - emoji: "👘", - aliases: ["kimono"], - tags: [], - category: "Objects", - description: "kimono", - unicode_version: "6.0", - }, - { - emoji: "🥻", - aliases: ["sari"], - tags: [], - category: "Objects", - description: "sari", - unicode_version: "12.0", - }, - { - emoji: "🩱", - aliases: ["one_piece_swimsuit"], - tags: [], - category: "Objects", - description: "one-piece swimsuit", - unicode_version: "12.0", - }, - { - emoji: "🩲", - aliases: ["swim_brief"], - tags: [], - category: "Objects", - description: "briefs", - unicode_version: "12.0", - }, - { - emoji: "🩳", - aliases: ["shorts"], - tags: [], - category: "Objects", - description: "shorts", - unicode_version: "12.0", - }, - { - emoji: "👙", - aliases: ["bikini"], - tags: ["beach"], - category: "Objects", - description: "bikini", - unicode_version: "6.0", - }, - { - emoji: "👚", - aliases: ["womans_clothes"], - tags: [], - category: "Objects", - description: "woman’s clothes", - unicode_version: "6.0", - }, - { - emoji: "👛", - aliases: ["purse"], - tags: [], - category: "Objects", - description: "purse", - unicode_version: "6.0", - }, - { - emoji: "👜", - aliases: ["handbag"], - tags: ["bag"], - category: "Objects", - description: "handbag", - unicode_version: "6.0", - }, - { - emoji: "👝", - aliases: ["pouch"], - tags: ["bag"], - category: "Objects", - description: "clutch bag", - unicode_version: "6.0", - }, - { - emoji: "🛍️", - aliases: ["shopping"], - tags: ["bags"], - category: "Objects", - description: "shopping bags", - unicode_version: "7.0", - }, - { - emoji: "🎒", - aliases: ["school_satchel"], - tags: [], - category: "Objects", - description: "backpack", - unicode_version: "6.0", - }, - { - emoji: "🩴", - aliases: ["thong_sandal"], - tags: [], - category: "Objects", - description: "thong sandal", - unicode_version: "13.0", - }, - { - emoji: "👞", - aliases: ["mans_shoe", "shoe"], - tags: [], - category: "Objects", - description: "man’s shoe", - unicode_version: "6.0", - }, - { - emoji: "👟", - aliases: ["athletic_shoe"], - tags: ["sneaker", "sport", "running"], - category: "Objects", - description: "running shoe", - unicode_version: "6.0", - }, - { - emoji: "🥾", - aliases: ["hiking_boot"], - tags: [], - category: "Objects", - description: "hiking boot", - unicode_version: "11.0", - }, - { - emoji: "🥿", - aliases: ["flat_shoe"], - tags: [], - category: "Objects", - description: "flat shoe", - unicode_version: "11.0", - }, - { - emoji: "👠", - aliases: ["high_heel"], - tags: ["shoe"], - category: "Objects", - description: "high-heeled shoe", - unicode_version: "6.0", - }, - { - emoji: "👡", - aliases: ["sandal"], - tags: ["shoe"], - category: "Objects", - description: "woman’s sandal", - unicode_version: "6.0", - }, - { - emoji: "🩰", - aliases: ["ballet_shoes"], - tags: [], - category: "Objects", - description: "ballet shoes", - unicode_version: "12.0", - }, - { - emoji: "👢", - aliases: ["boot"], - tags: [], - category: "Objects", - description: "woman’s boot", - unicode_version: "6.0", - }, - { - emoji: "👑", - aliases: ["crown"], - tags: ["king", "queen", "royal"], - category: "Objects", - description: "crown", - unicode_version: "6.0", - }, - { - emoji: "👒", - aliases: ["womans_hat"], - tags: [], - category: "Objects", - description: "woman’s hat", - unicode_version: "6.0", - }, - { - emoji: "🎩", - aliases: ["tophat"], - tags: ["hat", "classy"], - category: "Objects", - description: "top hat", - unicode_version: "6.0", - }, - { - emoji: "🎓", - aliases: ["mortar_board"], - tags: ["education", "college", "university", "graduation"], - category: "Objects", - description: "graduation cap", - unicode_version: "6.0", - }, - { - emoji: "🧢", - aliases: ["billed_cap"], - tags: [], - category: "Objects", - description: "billed cap", - unicode_version: "11.0", - }, - { - emoji: "🪖", - aliases: ["military_helmet"], - tags: [], - category: "Objects", - description: "military helmet", - unicode_version: "13.0", - }, - { - emoji: "⛑️", - aliases: ["rescue_worker_helmet"], - tags: [], - category: "Objects", - description: "rescue worker’s helmet", - unicode_version: "5.2", - }, - { - emoji: "📿", - aliases: ["prayer_beads"], - tags: [], - category: "Objects", - description: "prayer beads", - unicode_version: "8.0", - }, - { - emoji: "💄", - aliases: ["lipstick"], - tags: ["makeup"], - category: "Objects", - description: "lipstick", - unicode_version: "6.0", - }, - { - emoji: "💍", - aliases: ["ring"], - tags: ["wedding", "marriage", "engaged"], - category: "Objects", - description: "ring", - unicode_version: "6.0", - }, - { - emoji: "💎", - aliases: ["gem"], - tags: ["diamond"], - category: "Objects", - description: "gem stone", - unicode_version: "6.0", - }, - { - emoji: "🔇", - aliases: ["mute"], - tags: ["sound", "volume"], - category: "Objects", - description: "muted speaker", - unicode_version: "6.0", - }, - { - emoji: "🔈", - aliases: ["speaker"], - tags: [], - category: "Objects", - description: "speaker low volume", - unicode_version: "6.0", - }, - { - emoji: "🔉", - aliases: ["sound"], - tags: ["volume"], - category: "Objects", - description: "speaker medium volume", - unicode_version: "6.0", - }, - { - emoji: "🔊", - aliases: ["loud_sound"], - tags: ["volume"], - category: "Objects", - description: "speaker high volume", - unicode_version: "6.0", - }, - { - emoji: "📢", - aliases: ["loudspeaker"], - tags: ["announcement"], - category: "Objects", - description: "loudspeaker", - unicode_version: "6.0", - }, - { - emoji: "📣", - aliases: ["mega"], - tags: [], - category: "Objects", - description: "megaphone", - unicode_version: "6.0", - }, - { - emoji: "📯", - aliases: ["postal_horn"], - tags: [], - category: "Objects", - description: "postal horn", - unicode_version: "6.0", - }, - { - emoji: "🔔", - aliases: ["bell"], - tags: ["sound", "notification"], - category: "Objects", - description: "bell", - unicode_version: "6.0", - }, - { - emoji: "🔕", - aliases: ["no_bell"], - tags: ["volume", "off"], - category: "Objects", - description: "bell with slash", - unicode_version: "6.0", - }, - { - emoji: "🎼", - aliases: ["musical_score"], - tags: [], - category: "Objects", - description: "musical score", - unicode_version: "6.0", - }, - { - emoji: "🎵", - aliases: ["musical_note"], - tags: [], - category: "Objects", - description: "musical note", - unicode_version: "6.0", - }, - { - emoji: "🎶", - aliases: ["notes"], - tags: ["music"], - category: "Objects", - description: "musical notes", - unicode_version: "6.0", - }, - { - emoji: "🎙️", - aliases: ["studio_microphone"], - tags: ["podcast"], - category: "Objects", - description: "studio microphone", - unicode_version: "7.0", - }, - { - emoji: "🎚️", - aliases: ["level_slider"], - tags: [], - category: "Objects", - description: "level slider", - unicode_version: "7.0", - }, - { - emoji: "🎛️", - aliases: ["control_knobs"], - tags: [], - category: "Objects", - description: "control knobs", - unicode_version: "7.0", - }, - { - emoji: "🎤", - aliases: ["microphone"], - tags: ["sing"], - category: "Objects", - description: "microphone", - unicode_version: "6.0", - }, - { - emoji: "🎧", - aliases: ["headphones"], - tags: ["music", "earphones"], - category: "Objects", - description: "headphone", - unicode_version: "6.0", - }, - { - emoji: "📻", - aliases: ["radio"], - tags: ["podcast"], - category: "Objects", - description: "radio", - unicode_version: "6.0", - }, - { - emoji: "🎷", - aliases: ["saxophone"], - tags: [], - category: "Objects", - description: "saxophone", - unicode_version: "6.0", - }, - { - emoji: "🪗", - aliases: ["accordion"], - tags: [], - category: "Objects", - description: "accordion", - unicode_version: "13.0", - }, - { - emoji: "🎸", - aliases: ["guitar"], - tags: ["rock"], - category: "Objects", - description: "guitar", - unicode_version: "6.0", - }, - { - emoji: "🎹", - aliases: ["musical_keyboard"], - tags: ["piano"], - category: "Objects", - description: "musical keyboard", - unicode_version: "6.0", - }, - { - emoji: "🎺", - aliases: ["trumpet"], - tags: [], - category: "Objects", - description: "trumpet", - unicode_version: "6.0", - }, - { - emoji: "🎻", - aliases: ["violin"], - tags: [], - category: "Objects", - description: "violin", - unicode_version: "6.0", - }, - { - emoji: "🪕", - aliases: ["banjo"], - tags: [], - category: "Objects", - description: "banjo", - unicode_version: "12.0", - }, - { - emoji: "🥁", - aliases: ["drum"], - tags: [], - category: "Objects", - description: "drum", - unicode_version: "", - }, - { - emoji: "🪘", - aliases: ["long_drum"], - tags: [], - category: "Objects", - description: "long drum", - unicode_version: "13.0", - }, - { - emoji: "📱", - aliases: ["iphone"], - tags: ["smartphone", "mobile"], - category: "Objects", - description: "mobile phone", - unicode_version: "6.0", - }, - { - emoji: "📲", - aliases: ["calling"], - tags: ["call", "incoming"], - category: "Objects", - description: "mobile phone with arrow", - unicode_version: "6.0", - }, - { - emoji: "☎️", - aliases: ["phone", "telephone"], - tags: [], - category: "Objects", - description: "telephone", - unicode_version: "", - }, - { - emoji: "📞", - aliases: ["telephone_receiver"], - tags: ["phone", "call"], - category: "Objects", - description: "telephone receiver", - unicode_version: "6.0", - }, - { - emoji: "📟", - aliases: ["pager"], - tags: [], - category: "Objects", - description: "pager", - unicode_version: "6.0", - }, - { - emoji: "📠", - aliases: ["fax"], - tags: [], - category: "Objects", - description: "fax machine", - unicode_version: "6.0", - }, - { - emoji: "🔋", - aliases: ["battery"], - tags: ["power"], - category: "Objects", - description: "battery", - unicode_version: "6.0", - }, - { - emoji: "🔌", - aliases: ["electric_plug"], - tags: [], - category: "Objects", - description: "electric plug", - unicode_version: "6.0", - }, - { - emoji: "💻", - aliases: ["computer"], - tags: ["desktop", "screen"], - category: "Objects", - description: "laptop", - unicode_version: "6.0", - }, - { - emoji: "🖥️", - aliases: ["desktop_computer"], - tags: [], - category: "Objects", - description: "desktop computer", - unicode_version: "7.0", - }, - { - emoji: "🖨️", - aliases: ["printer"], - tags: [], - category: "Objects", - description: "printer", - unicode_version: "7.0", - }, - { - emoji: "⌨️", - aliases: ["keyboard"], - tags: [], - category: "Objects", - description: "keyboard", - unicode_version: "", - }, - { - emoji: "🖱️", - aliases: ["computer_mouse"], - tags: [], - category: "Objects", - description: "computer mouse", - unicode_version: "7.0", - }, - { - emoji: "🖲️", - aliases: ["trackball"], - tags: [], - category: "Objects", - description: "trackball", - unicode_version: "7.0", - }, - { - emoji: "💽", - aliases: ["minidisc"], - tags: [], - category: "Objects", - description: "computer disk", - unicode_version: "6.0", - }, - { - emoji: "💾", - aliases: ["floppy_disk"], - tags: ["save"], - category: "Objects", - description: "floppy disk", - unicode_version: "6.0", - }, - { - emoji: "💿", - aliases: ["cd"], - tags: [], - category: "Objects", - description: "optical disk", - unicode_version: "6.0", - }, - { - emoji: "📀", - aliases: ["dvd"], - tags: [], - category: "Objects", - description: "dvd", - unicode_version: "6.0", - }, - { - emoji: "🧮", - aliases: ["abacus"], - tags: [], - category: "Objects", - description: "abacus", - unicode_version: "11.0", - }, - { - emoji: "🎥", - aliases: ["movie_camera"], - tags: ["film", "video"], - category: "Objects", - description: "movie camera", - unicode_version: "6.0", - }, - { - emoji: "🎞️", - aliases: ["film_strip"], - tags: [], - category: "Objects", - description: "film frames", - unicode_version: "7.0", - }, - { - emoji: "📽️", - aliases: ["film_projector"], - tags: [], - category: "Objects", - description: "film projector", - unicode_version: "7.0", - }, - { - emoji: "🎬", - aliases: ["clapper"], - tags: ["film"], - category: "Objects", - description: "clapper board", - unicode_version: "6.0", - }, - { - emoji: "📺", - aliases: ["tv"], - tags: [], - category: "Objects", - description: "television", - unicode_version: "6.0", - }, - { - emoji: "📷", - aliases: ["camera"], - tags: ["photo"], - category: "Objects", - description: "camera", - unicode_version: "6.0", - }, - { - emoji: "📸", - aliases: ["camera_flash"], - tags: ["photo"], - category: "Objects", - description: "camera with flash", - unicode_version: "7.0", - }, - { - emoji: "📹", - aliases: ["video_camera"], - tags: [], - category: "Objects", - description: "video camera", - unicode_version: "6.0", - }, - { - emoji: "📼", - aliases: ["vhs"], - tags: [], - category: "Objects", - description: "videocassette", - unicode_version: "6.0", - }, - { - emoji: "🔍", - aliases: ["mag"], - tags: ["search", "zoom"], - category: "Objects", - description: "magnifying glass tilted left", - unicode_version: "6.0", - }, - { - emoji: "🔎", - aliases: ["mag_right"], - tags: [], - category: "Objects", - description: "magnifying glass tilted right", - unicode_version: "6.0", - }, - { - emoji: "🕯️", - aliases: ["candle"], - tags: [], - category: "Objects", - description: "candle", - unicode_version: "7.0", - }, - { - emoji: "💡", - aliases: ["bulb"], - tags: ["idea", "light"], - category: "Objects", - description: "light bulb", - unicode_version: "6.0", - }, - { - emoji: "🔦", - aliases: ["flashlight"], - tags: [], - category: "Objects", - description: "flashlight", - unicode_version: "6.0", - }, - { - emoji: "🏮", - aliases: ["izakaya_lantern", "lantern"], - tags: [], - category: "Objects", - description: "red paper lantern", - unicode_version: "6.0", - }, - { - emoji: "🪔", - aliases: ["diya_lamp"], - tags: [], - category: "Objects", - description: "diya lamp", - unicode_version: "12.0", - }, - { - emoji: "📔", - aliases: ["notebook_with_decorative_cover"], - tags: [], - category: "Objects", - description: "notebook with decorative cover", - unicode_version: "6.0", - }, - { - emoji: "📕", - aliases: ["closed_book"], - tags: [], - category: "Objects", - description: "closed book", - unicode_version: "6.0", - }, - { - emoji: "📖", - aliases: ["book", "open_book"], - tags: [], - category: "Objects", - description: "open book", - unicode_version: "6.0", - }, - { - emoji: "📗", - aliases: ["green_book"], - tags: [], - category: "Objects", - description: "green book", - unicode_version: "6.0", - }, - { - emoji: "📘", - aliases: ["blue_book"], - tags: [], - category: "Objects", - description: "blue book", - unicode_version: "6.0", - }, - { - emoji: "📙", - aliases: ["orange_book"], - tags: [], - category: "Objects", - description: "orange book", - unicode_version: "6.0", - }, - { - emoji: "📚", - aliases: ["books"], - tags: ["library"], - category: "Objects", - description: "books", - unicode_version: "6.0", - }, - { - emoji: "📓", - aliases: ["notebook"], - tags: [], - category: "Objects", - description: "notebook", - unicode_version: "6.0", - }, - { - emoji: "📒", - aliases: ["ledger"], - tags: [], - category: "Objects", - description: "ledger", - unicode_version: "6.0", - }, - { - emoji: "📃", - aliases: ["page_with_curl"], - tags: [], - category: "Objects", - description: "page with curl", - unicode_version: "6.0", - }, - { - emoji: "📜", - aliases: ["scroll"], - tags: ["document"], - category: "Objects", - description: "scroll", - unicode_version: "6.0", - }, - { - emoji: "📄", - aliases: ["page_facing_up"], - tags: ["document"], - category: "Objects", - description: "page facing up", - unicode_version: "6.0", - }, - { - emoji: "📰", - aliases: ["newspaper"], - tags: ["press"], - category: "Objects", - description: "newspaper", - unicode_version: "6.0", - }, - { - emoji: "🗞️", - aliases: ["newspaper_roll"], - tags: ["press"], - category: "Objects", - description: "rolled-up newspaper", - unicode_version: "7.0", - }, - { - emoji: "📑", - aliases: ["bookmark_tabs"], - tags: [], - category: "Objects", - description: "bookmark tabs", - unicode_version: "6.0", - }, - { - emoji: "🔖", - aliases: ["bookmark"], - tags: [], - category: "Objects", - description: "bookmark", - unicode_version: "6.0", - }, - { - emoji: "🏷️", - aliases: ["label"], - tags: ["tag"], - category: "Objects", - description: "label", - unicode_version: "7.0", - }, - { - emoji: "💰", - aliases: ["moneybag"], - tags: ["dollar", "cream"], - category: "Objects", - description: "money bag", - unicode_version: "6.0", - }, - { - emoji: "🪙", - aliases: ["coin"], - tags: [], - category: "Objects", - description: "coin", - unicode_version: "13.0", - }, - { - emoji: "💴", - aliases: ["yen"], - tags: [], - category: "Objects", - description: "yen banknote", - unicode_version: "6.0", - }, - { - emoji: "💵", - aliases: ["dollar"], - tags: ["money"], - category: "Objects", - description: "dollar banknote", - unicode_version: "6.0", - }, - { - emoji: "💶", - aliases: ["euro"], - tags: [], - category: "Objects", - description: "euro banknote", - unicode_version: "6.0", - }, - { - emoji: "💷", - aliases: ["pound"], - tags: [], - category: "Objects", - description: "pound banknote", - unicode_version: "6.0", - }, - { - emoji: "💸", - aliases: ["money_with_wings"], - tags: ["dollar"], - category: "Objects", - description: "money with wings", - unicode_version: "6.0", - }, - { - emoji: "💳", - aliases: ["credit_card"], - tags: ["subscription"], - category: "Objects", - description: "credit card", - unicode_version: "6.0", - }, - { - emoji: "🧾", - aliases: ["receipt"], - tags: [], - category: "Objects", - description: "receipt", - unicode_version: "11.0", - }, - { - emoji: "💹", - aliases: ["chart"], - tags: [], - category: "Objects", - description: "chart increasing with yen", - unicode_version: "6.0", - }, - { - emoji: "✉️", - aliases: ["envelope"], - tags: ["letter", "email"], - category: "Objects", - description: "envelope", - unicode_version: "", - }, - { - emoji: "📧", - aliases: ["email", "e-mail"], - tags: [], - category: "Objects", - description: "e-mail", - unicode_version: "6.0", - }, - { - emoji: "📨", - aliases: ["incoming_envelope"], - tags: [], - category: "Objects", - description: "incoming envelope", - unicode_version: "6.0", - }, - { - emoji: "📩", - aliases: ["envelope_with_arrow"], - tags: [], - category: "Objects", - description: "envelope with arrow", - unicode_version: "6.0", - }, - { - emoji: "📤", - aliases: ["outbox_tray"], - tags: [], - category: "Objects", - description: "outbox tray", - unicode_version: "6.0", - }, - { - emoji: "📥", - aliases: ["inbox_tray"], - tags: [], - category: "Objects", - description: "inbox tray", - unicode_version: "6.0", - }, - { - emoji: "📦", - aliases: ["package"], - tags: ["shipping"], - category: "Objects", - description: "package", - unicode_version: "6.0", - }, - { - emoji: "📫", - aliases: ["mailbox"], - tags: [], - category: "Objects", - description: "closed mailbox with raised flag", - unicode_version: "6.0", - }, - { - emoji: "📪", - aliases: ["mailbox_closed"], - tags: [], - category: "Objects", - description: "closed mailbox with lowered flag", - unicode_version: "6.0", - }, - { - emoji: "📬", - aliases: ["mailbox_with_mail"], - tags: [], - category: "Objects", - description: "open mailbox with raised flag", - unicode_version: "6.0", - }, - { - emoji: "📭", - aliases: ["mailbox_with_no_mail"], - tags: [], - category: "Objects", - description: "open mailbox with lowered flag", - unicode_version: "6.0", - }, - { - emoji: "📮", - aliases: ["postbox"], - tags: [], - category: "Objects", - description: "postbox", - unicode_version: "6.0", - }, - { - emoji: "🗳️", - aliases: ["ballot_box"], - tags: [], - category: "Objects", - description: "ballot box with ballot", - unicode_version: "7.0", - }, - { - emoji: "✏️", - aliases: ["pencil2"], - tags: [], - category: "Objects", - description: "pencil", - unicode_version: "", - }, - { - emoji: "✒️", - aliases: ["black_nib"], - tags: [], - category: "Objects", - description: "black nib", - unicode_version: "", - }, - { - emoji: "🖋️", - aliases: ["fountain_pen"], - tags: [], - category: "Objects", - description: "fountain pen", - unicode_version: "7.0", - }, - { - emoji: "🖊️", - aliases: ["pen"], - tags: [], - category: "Objects", - description: "pen", - unicode_version: "7.0", - }, - { - emoji: "🖌️", - aliases: ["paintbrush"], - tags: [], - category: "Objects", - description: "paintbrush", - unicode_version: "7.0", - }, - { - emoji: "🖍️", - aliases: ["crayon"], - tags: [], - category: "Objects", - description: "crayon", - unicode_version: "7.0", - }, - { - emoji: "📝", - aliases: ["memo", "pencil"], - tags: ["document", "note"], - category: "Objects", - description: "memo", - unicode_version: "6.0", - }, - { - emoji: "💼", - aliases: ["briefcase"], - tags: ["business"], - category: "Objects", - description: "briefcase", - unicode_version: "6.0", - }, - { - emoji: "📁", - aliases: ["file_folder"], - tags: ["directory"], - category: "Objects", - description: "file folder", - unicode_version: "6.0", - }, - { - emoji: "📂", - aliases: ["open_file_folder"], - tags: [], - category: "Objects", - description: "open file folder", - unicode_version: "6.0", - }, - { - emoji: "🗂️", - aliases: ["card_index_dividers"], - tags: [], - category: "Objects", - description: "card index dividers", - unicode_version: "7.0", - }, - { - emoji: "📅", - aliases: ["date"], - tags: ["calendar", "schedule"], - category: "Objects", - description: "calendar", - unicode_version: "6.0", - }, - { - emoji: "📆", - aliases: ["calendar"], - tags: ["schedule"], - category: "Objects", - description: "tear-off calendar", - unicode_version: "6.0", - }, - { - emoji: "🗒️", - aliases: ["spiral_notepad"], - tags: [], - category: "Objects", - description: "spiral notepad", - unicode_version: "7.0", - }, - { - emoji: "🗓️", - aliases: ["spiral_calendar"], - tags: [], - category: "Objects", - description: "spiral calendar", - unicode_version: "7.0", - }, - { - emoji: "📇", - aliases: ["card_index"], - tags: [], - category: "Objects", - description: "card index", - unicode_version: "6.0", - }, - { - emoji: "📈", - aliases: ["chart_with_upwards_trend"], - tags: ["graph", "metrics"], - category: "Objects", - description: "chart increasing", - unicode_version: "6.0", - }, - { - emoji: "📉", - aliases: ["chart_with_downwards_trend"], - tags: ["graph", "metrics"], - category: "Objects", - description: "chart decreasing", - unicode_version: "6.0", - }, - { - emoji: "📊", - aliases: ["bar_chart"], - tags: ["stats", "metrics"], - category: "Objects", - description: "bar chart", - unicode_version: "6.0", - }, - { - emoji: "📋", - aliases: ["clipboard"], - tags: [], - category: "Objects", - description: "clipboard", - unicode_version: "6.0", - }, - { - emoji: "📌", - aliases: ["pushpin"], - tags: ["location"], - category: "Objects", - description: "pushpin", - unicode_version: "6.0", - }, - { - emoji: "📍", - aliases: ["round_pushpin"], - tags: ["location"], - category: "Objects", - description: "round pushpin", - unicode_version: "6.0", - }, - { - emoji: "📎", - aliases: ["paperclip"], - tags: [], - category: "Objects", - description: "paperclip", - unicode_version: "6.0", - }, - { - emoji: "🖇️", - aliases: ["paperclips"], - tags: [], - category: "Objects", - description: "linked paperclips", - unicode_version: "7.0", - }, - { - emoji: "📏", - aliases: ["straight_ruler"], - tags: [], - category: "Objects", - description: "straight ruler", - unicode_version: "6.0", - }, - { - emoji: "📐", - aliases: ["triangular_ruler"], - tags: [], - category: "Objects", - description: "triangular ruler", - unicode_version: "6.0", - }, - { - emoji: "✂️", - aliases: ["scissors"], - tags: ["cut"], - category: "Objects", - description: "scissors", - unicode_version: "", - }, - { - emoji: "🗃️", - aliases: ["card_file_box"], - tags: [], - category: "Objects", - description: "card file box", - unicode_version: "7.0", - }, - { - emoji: "🗄️", - aliases: ["file_cabinet"], - tags: [], - category: "Objects", - description: "file cabinet", - unicode_version: "7.0", - }, - { - emoji: "🗑️", - aliases: ["wastebasket"], - tags: ["trash"], - category: "Objects", - description: "wastebasket", - unicode_version: "7.0", - }, - { - emoji: "🔒", - aliases: ["lock"], - tags: ["security", "private"], - category: "Objects", - description: "locked", - unicode_version: "6.0", - }, - { - emoji: "🔓", - aliases: ["unlock"], - tags: ["security"], - category: "Objects", - description: "unlocked", - unicode_version: "6.0", - }, - { - emoji: "🔏", - aliases: ["lock_with_ink_pen"], - tags: [], - category: "Objects", - description: "locked with pen", - unicode_version: "6.0", - }, - { - emoji: "🔐", - aliases: ["closed_lock_with_key"], - tags: ["security"], - category: "Objects", - description: "locked with key", - unicode_version: "6.0", - }, - { - emoji: "🔑", - aliases: ["key"], - tags: ["lock", "password"], - category: "Objects", - description: "key", - unicode_version: "6.0", - }, - { - emoji: "🗝️", - aliases: ["old_key"], - tags: [], - category: "Objects", - description: "old key", - unicode_version: "7.0", - }, - { - emoji: "🔨", - aliases: ["hammer"], - tags: ["tool"], - category: "Objects", - description: "hammer", - unicode_version: "6.0", - }, - { - emoji: "🪓", - aliases: ["axe"], - tags: [], - category: "Objects", - description: "axe", - unicode_version: "12.0", - }, - { - emoji: "⛏️", - aliases: ["pick"], - tags: [], - category: "Objects", - description: "pick", - unicode_version: "5.2", - }, - { - emoji: "⚒️", - aliases: ["hammer_and_pick"], - tags: [], - category: "Objects", - description: "hammer and pick", - unicode_version: "4.1", - }, - { - emoji: "🛠️", - aliases: ["hammer_and_wrench"], - tags: [], - category: "Objects", - description: "hammer and wrench", - unicode_version: "7.0", - }, - { - emoji: "🗡️", - aliases: ["dagger"], - tags: [], - category: "Objects", - description: "dagger", - unicode_version: "7.0", - }, - { - emoji: "⚔️", - aliases: ["crossed_swords"], - tags: [], - category: "Objects", - description: "crossed swords", - unicode_version: "4.1", - }, - { - emoji: "🔫", - aliases: ["gun"], - tags: ["shoot", "weapon"], - category: "Objects", - description: "water pistol", - unicode_version: "6.0", - }, - { - emoji: "🪃", - aliases: ["boomerang"], - tags: [], - category: "Objects", - description: "boomerang", - unicode_version: "13.0", - }, - { - emoji: "🏹", - aliases: ["bow_and_arrow"], - tags: ["archery"], - category: "Objects", - description: "bow and arrow", - unicode_version: "8.0", - }, - { - emoji: "🛡️", - aliases: ["shield"], - tags: [], - category: "Objects", - description: "shield", - unicode_version: "7.0", - }, - { - emoji: "🪚", - aliases: ["carpentry_saw"], - tags: [], - category: "Objects", - description: "carpentry saw", - unicode_version: "13.0", - }, - { - emoji: "🔧", - aliases: ["wrench"], - tags: ["tool"], - category: "Objects", - description: "wrench", - unicode_version: "6.0", - }, - { - emoji: "🪛", - aliases: ["screwdriver"], - tags: [], - category: "Objects", - description: "screwdriver", - unicode_version: "13.0", - }, - { - emoji: "🔩", - aliases: ["nut_and_bolt"], - tags: [], - category: "Objects", - description: "nut and bolt", - unicode_version: "6.0", - }, - { - emoji: "⚙️", - aliases: ["gear"], - tags: [], - category: "Objects", - description: "gear", - unicode_version: "4.1", - }, - { - emoji: "🗜️", - aliases: ["clamp"], - tags: [], - category: "Objects", - description: "clamp", - unicode_version: "7.0", - }, - { - emoji: "⚖️", - aliases: ["balance_scale"], - tags: [], - category: "Objects", - description: "balance scale", - unicode_version: "4.1", - }, - { - emoji: "🦯", - aliases: ["probing_cane"], - tags: [], - category: "Objects", - description: "white cane", - unicode_version: "12.0", - }, - { - emoji: "🔗", - aliases: ["link"], - tags: [], - category: "Objects", - description: "link", - unicode_version: "6.0", - }, - { - emoji: "⛓️", - aliases: ["chains"], - tags: [], - category: "Objects", - description: "chains", - unicode_version: "5.2", - }, - { - emoji: "🪝", - aliases: ["hook"], - tags: [], - category: "Objects", - description: "hook", - unicode_version: "13.0", - }, - { - emoji: "🧰", - aliases: ["toolbox"], - tags: [], - category: "Objects", - description: "toolbox", - unicode_version: "11.0", - }, - { - emoji: "🧲", - aliases: ["magnet"], - tags: [], - category: "Objects", - description: "magnet", - unicode_version: "11.0", - }, - { - emoji: "🪜", - aliases: ["ladder"], - tags: [], - category: "Objects", - description: "ladder", - unicode_version: "13.0", - }, - { - emoji: "⚗️", - aliases: ["alembic"], - tags: [], - category: "Objects", - description: "alembic", - unicode_version: "4.1", - }, - { - emoji: "🧪", - aliases: ["test_tube"], - tags: [], - category: "Objects", - description: "test tube", - unicode_version: "11.0", - }, - { - emoji: "🧫", - aliases: ["petri_dish"], - tags: [], - category: "Objects", - description: "petri dish", - unicode_version: "11.0", - }, - { - emoji: "🧬", - aliases: ["dna"], - tags: [], - category: "Objects", - description: "dna", - unicode_version: "11.0", - }, - { - emoji: "🔬", - aliases: ["microscope"], - tags: ["science", "laboratory", "investigate"], - category: "Objects", - description: "microscope", - unicode_version: "6.0", - }, - { - emoji: "🔭", - aliases: ["telescope"], - tags: [], - category: "Objects", - description: "telescope", - unicode_version: "6.0", - }, - { - emoji: "📡", - aliases: ["satellite"], - tags: ["signal"], - category: "Objects", - description: "satellite antenna", - unicode_version: "6.0", - }, - { - emoji: "💉", - aliases: ["syringe"], - tags: ["health", "hospital", "needle"], - category: "Objects", - description: "syringe", - unicode_version: "6.0", - }, - { - emoji: "🩸", - aliases: ["drop_of_blood"], - tags: [], - category: "Objects", - description: "drop of blood", - unicode_version: "12.0", - }, - { - emoji: "💊", - aliases: ["pill"], - tags: ["health", "medicine"], - category: "Objects", - description: "pill", - unicode_version: "6.0", - }, - { - emoji: "🩹", - aliases: ["adhesive_bandage"], - tags: [], - category: "Objects", - description: "adhesive bandage", - unicode_version: "12.0", - }, - { - emoji: "🩺", - aliases: ["stethoscope"], - tags: [], - category: "Objects", - description: "stethoscope", - unicode_version: "12.0", - }, - { - emoji: "🚪", - aliases: ["door"], - tags: [], - category: "Objects", - description: "door", - unicode_version: "6.0", - }, - { - emoji: "🛗", - aliases: ["elevator"], - tags: [], - category: "Objects", - description: "elevator", - unicode_version: "13.0", - }, - { - emoji: "🪞", - aliases: ["mirror"], - tags: [], - category: "Objects", - description: "mirror", - unicode_version: "13.0", - }, - { - emoji: "🪟", - aliases: ["window"], - tags: [], - category: "Objects", - description: "window", - unicode_version: "13.0", - }, - { - emoji: "🛏️", - aliases: ["bed"], - tags: [], - category: "Objects", - description: "bed", - unicode_version: "7.0", - }, - { - emoji: "🛋️", - aliases: ["couch_and_lamp"], - tags: [], - category: "Objects", - description: "couch and lamp", - unicode_version: "7.0", - }, - { - emoji: "🪑", - aliases: ["chair"], - tags: [], - category: "Objects", - description: "chair", - unicode_version: "12.0", - }, - { - emoji: "🚽", - aliases: ["toilet"], - tags: ["wc"], - category: "Objects", - description: "toilet", - unicode_version: "6.0", - }, - { - emoji: "🪠", - aliases: ["plunger"], - tags: [], - category: "Objects", - description: "plunger", - unicode_version: "13.0", - }, - { - emoji: "🚿", - aliases: ["shower"], - tags: ["bath"], - category: "Objects", - description: "shower", - unicode_version: "6.0", - }, - { - emoji: "🛁", - aliases: ["bathtub"], - tags: [], - category: "Objects", - description: "bathtub", - unicode_version: "6.0", - }, - { - emoji: "🪤", - aliases: ["mouse_trap"], - tags: [], - category: "Objects", - description: "mouse trap", - unicode_version: "13.0", - }, - { - emoji: "🪒", - aliases: ["razor"], - tags: [], - category: "Objects", - description: "razor", - unicode_version: "12.0", - }, - { - emoji: "🧴", - aliases: ["lotion_bottle"], - tags: [], - category: "Objects", - description: "lotion bottle", - unicode_version: "11.0", - }, - { - emoji: "🧷", - aliases: ["safety_pin"], - tags: [], - category: "Objects", - description: "safety pin", - unicode_version: "11.0", - }, - { - emoji: "🧹", - aliases: ["broom"], - tags: [], - category: "Objects", - description: "broom", - unicode_version: "11.0", - }, - { - emoji: "🧺", - aliases: ["basket"], - tags: [], - category: "Objects", - description: "basket", - unicode_version: "11.0", - }, - { - emoji: "🧻", - aliases: ["roll_of_paper"], - tags: ["toilet"], - category: "Objects", - description: "roll of paper", - unicode_version: "11.0", - }, - { - emoji: "🪣", - aliases: ["bucket"], - tags: [], - category: "Objects", - description: "bucket", - unicode_version: "13.0", - }, - { - emoji: "🧼", - aliases: ["soap"], - tags: [], - category: "Objects", - description: "soap", - unicode_version: "11.0", - }, - { - emoji: "🪥", - aliases: ["toothbrush"], - tags: [], - category: "Objects", - description: "toothbrush", - unicode_version: "13.0", - }, - { - emoji: "🧽", - aliases: ["sponge"], - tags: [], - category: "Objects", - description: "sponge", - unicode_version: "11.0", - }, - { - emoji: "🧯", - aliases: ["fire_extinguisher"], - tags: [], - category: "Objects", - description: "fire extinguisher", - unicode_version: "11.0", - }, - { - emoji: "🛒", - aliases: ["shopping_cart"], - tags: [], - category: "Objects", - description: "shopping cart", - unicode_version: "9.0", - }, - { - emoji: "🚬", - aliases: ["smoking"], - tags: ["cigarette"], - category: "Objects", - description: "cigarette", - unicode_version: "6.0", - }, - { - emoji: "⚰️", - aliases: ["coffin"], - tags: ["funeral"], - category: "Objects", - description: "coffin", - unicode_version: "4.1", - }, - { - emoji: "🪦", - aliases: ["headstone"], - tags: [], - category: "Objects", - description: "headstone", - unicode_version: "13.0", - }, - { - emoji: "⚱️", - aliases: ["funeral_urn"], - tags: [], - category: "Objects", - description: "funeral urn", - unicode_version: "4.1", - }, - { - emoji: "🗿", - aliases: ["moyai"], - tags: ["stone"], - category: "Objects", - description: "moai", - unicode_version: "6.0", - }, - { - emoji: "🪧", - aliases: ["placard"], - tags: [], - category: "Objects", - description: "placard", - unicode_version: "13.0", - }, - { - emoji: "🏧", - aliases: ["atm"], - tags: [], - category: "Symbols", - description: "ATM sign", - unicode_version: "6.0", - }, - { - emoji: "🚮", - aliases: ["put_litter_in_its_place"], - tags: [], - category: "Symbols", - description: "litter in bin sign", - unicode_version: "6.0", - }, - { - emoji: "🚰", - aliases: ["potable_water"], - tags: [], - category: "Symbols", - description: "potable water", - unicode_version: "6.0", - }, - { - emoji: "♿", - aliases: ["wheelchair"], - tags: ["accessibility"], - category: "Symbols", - description: "wheelchair symbol", - unicode_version: "4.1", - }, - { - emoji: "🚹", - aliases: ["mens"], - tags: [], - category: "Symbols", - description: "men’s room", - unicode_version: "6.0", - }, - { - emoji: "🚺", - aliases: ["womens"], - tags: [], - category: "Symbols", - description: "women’s room", - unicode_version: "6.0", - }, - { - emoji: "🚻", - aliases: ["restroom"], - tags: ["toilet"], - category: "Symbols", - description: "restroom", - unicode_version: "6.0", - }, - { - emoji: "🚼", - aliases: ["baby_symbol"], - tags: [], - category: "Symbols", - description: "baby symbol", - unicode_version: "6.0", - }, - { - emoji: "🚾", - aliases: ["wc"], - tags: ["toilet", "restroom"], - category: "Symbols", - description: "water closet", - unicode_version: "6.0", - }, - { - emoji: "🛂", - aliases: ["passport_control"], - tags: [], - category: "Symbols", - description: "passport control", - unicode_version: "6.0", - }, - { - emoji: "🛃", - aliases: ["customs"], - tags: [], - category: "Symbols", - description: "customs", - unicode_version: "6.0", - }, - { - emoji: "🛄", - aliases: ["baggage_claim"], - tags: ["airport"], - category: "Symbols", - description: "baggage claim", - unicode_version: "6.0", - }, - { - emoji: "🛅", - aliases: ["left_luggage"], - tags: [], - category: "Symbols", - description: "left luggage", - unicode_version: "6.0", - }, - { - emoji: "⚠️", - aliases: ["warning"], - tags: ["wip"], - category: "Symbols", - description: "warning", - unicode_version: "4.0", - }, - { - emoji: "🚸", - aliases: ["children_crossing"], - tags: [], - category: "Symbols", - description: "children crossing", - unicode_version: "6.0", - }, - { - emoji: "⛔", - aliases: ["no_entry"], - tags: ["limit"], - category: "Symbols", - description: "no entry", - unicode_version: "5.2", - }, - { - emoji: "🚫", - aliases: ["no_entry_sign"], - tags: ["block", "forbidden"], - category: "Symbols", - description: "prohibited", - unicode_version: "6.0", - }, - { - emoji: "🚳", - aliases: ["no_bicycles"], - tags: [], - category: "Symbols", - description: "no bicycles", - unicode_version: "6.0", - }, - { - emoji: "🚭", - aliases: ["no_smoking"], - tags: [], - category: "Symbols", - description: "no smoking", - unicode_version: "6.0", - }, - { - emoji: "🚯", - aliases: ["do_not_litter"], - tags: [], - category: "Symbols", - description: "no littering", - unicode_version: "6.0", - }, - { - emoji: "🚱", - aliases: ["non-potable_water"], - tags: [], - category: "Symbols", - description: "non-potable water", - unicode_version: "6.0", - }, - { - emoji: "🚷", - aliases: ["no_pedestrians"], - tags: [], - category: "Symbols", - description: "no pedestrians", - unicode_version: "6.0", - }, - { - emoji: "📵", - aliases: ["no_mobile_phones"], - tags: [], - category: "Symbols", - description: "no mobile phones", - unicode_version: "6.0", - }, - { - emoji: "🔞", - aliases: ["underage"], - tags: [], - category: "Symbols", - description: "no one under eighteen", - unicode_version: "6.0", - }, - { - emoji: "☢️", - aliases: ["radioactive"], - tags: [], - category: "Symbols", - description: "radioactive", - unicode_version: "", - }, - { - emoji: "☣️", - aliases: ["biohazard"], - tags: [], - category: "Symbols", - description: "biohazard", - unicode_version: "", - }, - { - emoji: "⬆️", - aliases: ["arrow_up"], - tags: [], - category: "Symbols", - description: "up arrow", - unicode_version: "4.0", - }, - { - emoji: "↗️", - aliases: ["arrow_upper_right"], - tags: [], - category: "Symbols", - description: "up-right arrow", - unicode_version: "", - }, - { - emoji: "➡️", - aliases: ["arrow_right"], - tags: [], - category: "Symbols", - description: "right arrow", - unicode_version: "", - }, - { - emoji: "↘️", - aliases: ["arrow_lower_right"], - tags: [], - category: "Symbols", - description: "down-right arrow", - unicode_version: "", - }, - { - emoji: "⬇️", - aliases: ["arrow_down"], - tags: [], - category: "Symbols", - description: "down arrow", - unicode_version: "4.0", - }, - { - emoji: "↙️", - aliases: ["arrow_lower_left"], - tags: [], - category: "Symbols", - description: "down-left arrow", - unicode_version: "", - }, - { - emoji: "⬅️", - aliases: ["arrow_left"], - tags: [], - category: "Symbols", - description: "left arrow", - unicode_version: "4.0", - }, - { - emoji: "↖️", - aliases: ["arrow_upper_left"], - tags: [], - category: "Symbols", - description: "up-left arrow", - unicode_version: "", - }, - { - emoji: "↕️", - aliases: ["arrow_up_down"], - tags: [], - category: "Symbols", - description: "up-down arrow", - unicode_version: "", - }, - { - emoji: "↔️", - aliases: ["left_right_arrow"], - tags: [], - category: "Symbols", - description: "left-right arrow", - unicode_version: "", - }, - { - emoji: "↩️", - aliases: ["leftwards_arrow_with_hook"], - tags: ["return"], - category: "Symbols", - description: "right arrow curving left", - unicode_version: "", - }, - { - emoji: "↪️", - aliases: ["arrow_right_hook"], - tags: [], - category: "Symbols", - description: "left arrow curving right", - unicode_version: "", - }, - { - emoji: "⤴️", - aliases: ["arrow_heading_up"], - tags: [], - category: "Symbols", - description: "right arrow curving up", - unicode_version: "", - }, - { - emoji: "⤵️", - aliases: ["arrow_heading_down"], - tags: [], - category: "Symbols", - description: "right arrow curving down", - unicode_version: "", - }, - { - emoji: "🔃", - aliases: ["arrows_clockwise"], - tags: [], - category: "Symbols", - description: "clockwise vertical arrows", - unicode_version: "6.0", - }, - { - emoji: "🔄", - aliases: ["arrows_counterclockwise"], - tags: ["sync"], - category: "Symbols", - description: "counterclockwise arrows button", - unicode_version: "6.0", - }, - { - emoji: "🔙", - aliases: ["back"], - tags: [], - category: "Symbols", - description: "BACK arrow", - unicode_version: "6.0", - }, - { - emoji: "🔚", - aliases: ["end"], - tags: [], - category: "Symbols", - description: "END arrow", - unicode_version: "6.0", - }, - { - emoji: "🔛", - aliases: ["on"], - tags: [], - category: "Symbols", - description: "ON! arrow", - unicode_version: "6.0", - }, - { - emoji: "🔜", - aliases: ["soon"], - tags: [], - category: "Symbols", - description: "SOON arrow", - unicode_version: "6.0", - }, - { - emoji: "🔝", - aliases: ["top"], - tags: [], - category: "Symbols", - description: "TOP arrow", - unicode_version: "6.0", - }, - { - emoji: "🛐", - aliases: ["place_of_worship"], - tags: [], - category: "Symbols", - description: "place of worship", - unicode_version: "8.0", - }, - { - emoji: "⚛️", - aliases: ["atom_symbol"], - tags: [], - category: "Symbols", - description: "atom symbol", - unicode_version: "4.1", - }, - { - emoji: "🕉️", - aliases: ["om"], - tags: [], - category: "Symbols", - description: "om", - unicode_version: "7.0", - }, - { - emoji: "✡️", - aliases: ["star_of_david"], - tags: [], - category: "Symbols", - description: "star of David", - unicode_version: "", - }, - { - emoji: "☸️", - aliases: ["wheel_of_dharma"], - tags: [], - category: "Symbols", - description: "wheel of dharma", - unicode_version: "", - }, - { - emoji: "☯️", - aliases: ["yin_yang"], - tags: [], - category: "Symbols", - description: "yin yang", - unicode_version: "", - }, - { - emoji: "✝️", - aliases: ["latin_cross"], - tags: [], - category: "Symbols", - description: "latin cross", - unicode_version: "", - }, - { - emoji: "☦️", - aliases: ["orthodox_cross"], - tags: [], - category: "Symbols", - description: "orthodox cross", - unicode_version: "", - }, - { - emoji: "☪️", - aliases: ["star_and_crescent"], - tags: [], - category: "Symbols", - description: "star and crescent", - unicode_version: "", - }, - { - emoji: "☮️", - aliases: ["peace_symbol"], - tags: [], - category: "Symbols", - description: "peace symbol", - unicode_version: "", - }, - { - emoji: "🕎", - aliases: ["menorah"], - tags: [], - category: "Symbols", - description: "menorah", - unicode_version: "8.0", - }, - { - emoji: "🔯", - aliases: ["six_pointed_star"], - tags: [], - category: "Symbols", - description: "dotted six-pointed star", - unicode_version: "6.0", - }, - { - emoji: "♈", - aliases: ["aries"], - tags: [], - category: "Symbols", - description: "Aries", - unicode_version: "", - }, - { - emoji: "♉", - aliases: ["taurus"], - tags: [], - category: "Symbols", - description: "Taurus", - unicode_version: "", - }, - { - emoji: "♊", - aliases: ["gemini"], - tags: [], - category: "Symbols", - description: "Gemini", - unicode_version: "", - }, - { - emoji: "♋", - aliases: ["cancer"], - tags: [], - category: "Symbols", - description: "Cancer", - unicode_version: "", - }, - { - emoji: "♌", - aliases: ["leo"], - tags: [], - category: "Symbols", - description: "Leo", - unicode_version: "", - }, - { - emoji: "♍", - aliases: ["virgo"], - tags: [], - category: "Symbols", - description: "Virgo", - unicode_version: "", - }, - { - emoji: "♎", - aliases: ["libra"], - tags: [], - category: "Symbols", - description: "Libra", - unicode_version: "", - }, - { - emoji: "♏", - aliases: ["scorpius"], - tags: [], - category: "Symbols", - description: "Scorpio", - unicode_version: "", - }, - { - emoji: "♐", - aliases: ["sagittarius"], - tags: [], - category: "Symbols", - description: "Sagittarius", - unicode_version: "", - }, - { - emoji: "♑", - aliases: ["capricorn"], - tags: [], - category: "Symbols", - description: "Capricorn", - unicode_version: "", - }, - { - emoji: "♒", - aliases: ["aquarius"], - tags: [], - category: "Symbols", - description: "Aquarius", - unicode_version: "", - }, - { - emoji: "♓", - aliases: ["pisces"], - tags: [], - category: "Symbols", - description: "Pisces", - unicode_version: "", - }, - { - emoji: "⛎", - aliases: ["ophiuchus"], - tags: [], - category: "Symbols", - description: "Ophiuchus", - unicode_version: "6.0", - }, - { - emoji: "🔀", - aliases: ["twisted_rightwards_arrows"], - tags: ["shuffle"], - category: "Symbols", - description: "shuffle tracks button", - unicode_version: "6.0", - }, - { - emoji: "🔁", - aliases: ["repeat"], - tags: ["loop"], - category: "Symbols", - description: "repeat button", - unicode_version: "6.0", - }, - { - emoji: "🔂", - aliases: ["repeat_one"], - tags: [], - category: "Symbols", - description: "repeat single button", - unicode_version: "6.0", - }, - { - emoji: "▶️", - aliases: ["arrow_forward"], - tags: [], - category: "Symbols", - description: "play button", - unicode_version: "", - }, - { - emoji: "⏩", - aliases: ["fast_forward"], - tags: [], - category: "Symbols", - description: "fast-forward button", - unicode_version: "6.0", - }, - { - emoji: "⏭️", - aliases: ["next_track_button"], - tags: [], - category: "Symbols", - description: "next track button", - unicode_version: "6.0", - }, - { - emoji: "⏯️", - aliases: ["play_or_pause_button"], - tags: [], - category: "Symbols", - description: "play or pause button", - unicode_version: "6.0", - }, - { - emoji: "◀️", - aliases: ["arrow_backward"], - tags: [], - category: "Symbols", - description: "reverse button", - unicode_version: "", - }, - { - emoji: "⏪", - aliases: ["rewind"], - tags: [], - category: "Symbols", - description: "fast reverse button", - unicode_version: "6.0", - }, - { - emoji: "⏮️", - aliases: ["previous_track_button"], - tags: [], - category: "Symbols", - description: "last track button", - unicode_version: "6.0", - }, - { - emoji: "🔼", - aliases: ["arrow_up_small"], - tags: [], - category: "Symbols", - description: "upwards button", - unicode_version: "6.0", - }, - { - emoji: "⏫", - aliases: ["arrow_double_up"], - tags: [], - category: "Symbols", - description: "fast up button", - unicode_version: "6.0", - }, - { - emoji: "🔽", - aliases: ["arrow_down_small"], - tags: [], - category: "Symbols", - description: "downwards button", - unicode_version: "6.0", - }, - { - emoji: "⏬", - aliases: ["arrow_double_down"], - tags: [], - category: "Symbols", - description: "fast down button", - unicode_version: "6.0", - }, - { - emoji: "⏸️", - aliases: ["pause_button"], - tags: [], - category: "Symbols", - description: "pause button", - unicode_version: "7.0", - }, - { - emoji: "⏹️", - aliases: ["stop_button"], - tags: [], - category: "Symbols", - description: "stop button", - unicode_version: "7.0", - }, - { - emoji: "⏺️", - aliases: ["record_button"], - tags: [], - category: "Symbols", - description: "record button", - unicode_version: "7.0", - }, - { - emoji: "⏏️", - aliases: ["eject_button"], - tags: [], - category: "Symbols", - description: "eject button", - unicode_version: "11.0", - }, - { - emoji: "🎦", - aliases: ["cinema"], - tags: ["film", "movie"], - category: "Symbols", - description: "cinema", - unicode_version: "6.0", - }, - { - emoji: "🔅", - aliases: ["low_brightness"], - tags: [], - category: "Symbols", - description: "dim button", - unicode_version: "6.0", - }, - { - emoji: "🔆", - aliases: ["high_brightness"], - tags: [], - category: "Symbols", - description: "bright button", - unicode_version: "6.0", - }, - { - emoji: "📶", - aliases: ["signal_strength"], - tags: ["wifi"], - category: "Symbols", - description: "antenna bars", - unicode_version: "6.0", - }, - { - emoji: "📳", - aliases: ["vibration_mode"], - tags: [], - category: "Symbols", - description: "vibration mode", - unicode_version: "6.0", - }, - { - emoji: "📴", - aliases: ["mobile_phone_off"], - tags: ["mute", "off"], - category: "Symbols", - description: "mobile phone off", - unicode_version: "6.0", - }, - { - emoji: "♀️", - aliases: ["female_sign"], - tags: [], - category: "Symbols", - description: "female sign", - unicode_version: "11.0", - }, - { - emoji: "♂️", - aliases: ["male_sign"], - tags: [], - category: "Symbols", - description: "male sign", - unicode_version: "11.0", - }, - { - emoji: "⚧️", - aliases: ["transgender_symbol"], - tags: [], - category: "Symbols", - description: "transgender symbol", - unicode_version: "13.0", - }, - { - emoji: "✖️", - aliases: ["heavy_multiplication_x"], - tags: [], - category: "Symbols", - description: "multiply", - unicode_version: "", - }, - { - emoji: "➕", - aliases: ["heavy_plus_sign"], - tags: [], - category: "Symbols", - description: "plus", - unicode_version: "6.0", - }, - { - emoji: "➖", - aliases: ["heavy_minus_sign"], - tags: [], - category: "Symbols", - description: "minus", - unicode_version: "6.0", - }, - { - emoji: "➗", - aliases: ["heavy_division_sign"], - tags: [], - category: "Symbols", - description: "divide", - unicode_version: "6.0", - }, - { - emoji: "♾️", - aliases: ["infinity"], - tags: [], - category: "Symbols", - description: "infinity", - unicode_version: "11.0", - }, - { - emoji: "‼️", - aliases: ["bangbang"], - tags: [], - category: "Symbols", - description: "double exclamation mark", - unicode_version: "", - }, - { - emoji: "⁉️", - aliases: ["interrobang"], - tags: [], - category: "Symbols", - description: "exclamation question mark", - unicode_version: "3.0", - }, - { - emoji: "❓", - aliases: ["question"], - tags: ["confused"], - category: "Symbols", - description: "red question mark", - unicode_version: "6.0", - }, - { - emoji: "❔", - aliases: ["grey_question"], - tags: [], - category: "Symbols", - description: "white question mark", - unicode_version: "6.0", - }, - { - emoji: "❕", - aliases: ["grey_exclamation"], - tags: [], - category: "Symbols", - description: "white exclamation mark", - unicode_version: "6.0", - }, - { - emoji: "❗", - aliases: ["exclamation", "heavy_exclamation_mark"], - tags: ["bang"], - category: "Symbols", - description: "red exclamation mark", - unicode_version: "5.2", - }, - { - emoji: "〰️", - aliases: ["wavy_dash"], - tags: [], - category: "Symbols", - description: "wavy dash", - unicode_version: "", - }, - { - emoji: "💱", - aliases: ["currency_exchange"], - tags: [], - category: "Symbols", - description: "currency exchange", - unicode_version: "6.0", - }, - { - emoji: "💲", - aliases: ["heavy_dollar_sign"], - tags: [], - category: "Symbols", - description: "heavy dollar sign", - unicode_version: "6.0", - }, - { - emoji: "⚕️", - aliases: ["medical_symbol"], - tags: [], - category: "Symbols", - description: "medical symbol", - unicode_version: "11.0", - }, - { - emoji: "♻️", - aliases: ["recycle"], - tags: ["environment", "green"], - category: "Symbols", - description: "recycling symbol", - unicode_version: "3.2", - }, - { - emoji: "⚜️", - aliases: ["fleur_de_lis"], - tags: [], - category: "Symbols", - description: "fleur-de-lis", - unicode_version: "4.1", - }, - { - emoji: "🔱", - aliases: ["trident"], - tags: [], - category: "Symbols", - description: "trident emblem", - unicode_version: "6.0", - }, - { - emoji: "📛", - aliases: ["name_badge"], - tags: [], - category: "Symbols", - description: "name badge", - unicode_version: "6.0", - }, - { - emoji: "🔰", - aliases: ["beginner"], - tags: [], - category: "Symbols", - description: "Japanese symbol for beginner", - unicode_version: "6.0", - }, - { - emoji: "⭕", - aliases: ["o"], - tags: [], - category: "Symbols", - description: "hollow red circle", - unicode_version: "5.2", - }, - { - emoji: "✅", - aliases: ["white_check_mark"], - tags: [], - category: "Symbols", - description: "check mark button", - unicode_version: "6.0", - }, - { - emoji: "☑️", - aliases: ["ballot_box_with_check"], - tags: [], - category: "Symbols", - description: "check box with check", - unicode_version: "", - }, - { - emoji: "✔️", - aliases: ["heavy_check_mark"], - tags: [], - category: "Symbols", - description: "check mark", - unicode_version: "", - }, - { - emoji: "❌", - aliases: ["x"], - tags: [], - category: "Symbols", - description: "cross mark", - unicode_version: "6.0", - }, - { - emoji: "❎", - aliases: ["negative_squared_cross_mark"], - tags: [], - category: "Symbols", - description: "cross mark button", - unicode_version: "6.0", - }, - { - emoji: "➰", - aliases: ["curly_loop"], - tags: [], - category: "Symbols", - description: "curly loop", - unicode_version: "6.0", - }, - { - emoji: "➿", - aliases: ["loop"], - tags: [], - category: "Symbols", - description: "double curly loop", - unicode_version: "6.0", - }, - { - emoji: "〽️", - aliases: ["part_alternation_mark"], - tags: [], - category: "Symbols", - description: "part alternation mark", - unicode_version: "3.2", - }, - { - emoji: "✳️", - aliases: ["eight_spoked_asterisk"], - tags: [], - category: "Symbols", - description: "eight-spoked asterisk", - unicode_version: "", - }, - { - emoji: "✴️", - aliases: ["eight_pointed_black_star"], - tags: [], - category: "Symbols", - description: "eight-pointed star", - unicode_version: "", - }, - { - emoji: "❇️", - aliases: ["sparkle"], - tags: [], - category: "Symbols", - description: "sparkle", - unicode_version: "", - }, - { - emoji: "©️", - aliases: ["copyright"], - tags: [], - category: "Symbols", - description: "copyright", - unicode_version: "", - }, - { - emoji: "®️", - aliases: ["registered"], - tags: [], - category: "Symbols", - description: "registered", - unicode_version: "", - }, - { - emoji: "™️", - aliases: ["tm"], - tags: ["trademark"], - category: "Symbols", - description: "trade mark", - unicode_version: "", - }, - { - emoji: "#️⃣", - aliases: ["hash"], - tags: ["number"], - category: "Symbols", - description: "keycap: #", - unicode_version: "", - }, - { - emoji: "*️⃣", - aliases: ["asterisk"], - tags: [], - category: "Symbols", - description: "keycap: *", - unicode_version: "", - }, - { - emoji: "0️⃣", - aliases: ["zero"], - tags: [], - category: "Symbols", - description: "keycap: 0", - unicode_version: "", - }, - { - emoji: "1️⃣", - aliases: ["one"], - tags: [], - category: "Symbols", - description: "keycap: 1", - unicode_version: "", - }, - { - emoji: "2️⃣", - aliases: ["two"], - tags: [], - category: "Symbols", - description: "keycap: 2", - unicode_version: "", - }, - { - emoji: "3️⃣", - aliases: ["three"], - tags: [], - category: "Symbols", - description: "keycap: 3", - unicode_version: "", - }, - { - emoji: "4️⃣", - aliases: ["four"], - tags: [], - category: "Symbols", - description: "keycap: 4", - unicode_version: "", - }, - { - emoji: "5️⃣", - aliases: ["five"], - tags: [], - category: "Symbols", - description: "keycap: 5", - unicode_version: "", - }, - { - emoji: "6️⃣", - aliases: ["six"], - tags: [], - category: "Symbols", - description: "keycap: 6", - unicode_version: "", - }, - { - emoji: "7️⃣", - aliases: ["seven"], - tags: [], - category: "Symbols", - description: "keycap: 7", - unicode_version: "", - }, - { - emoji: "8️⃣", - aliases: ["eight"], - tags: [], - category: "Symbols", - description: "keycap: 8", - unicode_version: "", - }, - { - emoji: "9️⃣", - aliases: ["nine"], - tags: [], - category: "Symbols", - description: "keycap: 9", - unicode_version: "", - }, - { - emoji: "🔟", - aliases: ["keycap_ten"], - tags: [], - category: "Symbols", - description: "keycap: 10", - unicode_version: "6.0", - }, - { - emoji: "🔠", - aliases: ["capital_abcd"], - tags: ["letters"], - category: "Symbols", - description: "input latin uppercase", - unicode_version: "6.0", - }, - { - emoji: "🔡", - aliases: ["abcd"], - tags: [], - category: "Symbols", - description: "input latin lowercase", - unicode_version: "6.0", - }, - { - emoji: "🔢", - aliases: ["1234"], - tags: ["numbers"], - category: "Symbols", - description: "input numbers", - unicode_version: "6.0", - }, - { - emoji: "🔣", - aliases: ["symbols"], - tags: [], - category: "Symbols", - description: "input symbols", - unicode_version: "6.0", - }, - { - emoji: "🔤", - aliases: ["abc"], - tags: ["alphabet"], - category: "Symbols", - description: "input latin letters", - unicode_version: "6.0", - }, - { - emoji: "🅰️", - aliases: ["a"], - tags: [], - category: "Symbols", - description: "A button (blood type)", - unicode_version: "6.0", - }, - { - emoji: "🆎", - aliases: ["ab"], - tags: [], - category: "Symbols", - description: "AB button (blood type)", - unicode_version: "6.0", - }, - { - emoji: "🅱️", - aliases: ["b"], - tags: [], - category: "Symbols", - description: "B button (blood type)", - unicode_version: "6.0", - }, - { - emoji: "🆑", - aliases: ["cl"], - tags: [], - category: "Symbols", - description: "CL button", - unicode_version: "6.0", - }, - { - emoji: "🆒", - aliases: ["cool"], - tags: [], - category: "Symbols", - description: "COOL button", - unicode_version: "6.0", - }, - { - emoji: "🆓", - aliases: ["free"], - tags: [], - category: "Symbols", - description: "FREE button", - unicode_version: "6.0", - }, - { - emoji: "ℹ️", - aliases: ["information_source"], - tags: [], - category: "Symbols", - description: "information", - unicode_version: "3.0", - }, - { - emoji: "🆔", - aliases: ["id"], - tags: [], - category: "Symbols", - description: "ID button", - unicode_version: "6.0", - }, - { - emoji: "Ⓜ️", - aliases: ["m"], - tags: [], - category: "Symbols", - description: "circled M", - unicode_version: "", - }, - { - emoji: "🆕", - aliases: ["new"], - tags: ["fresh"], - category: "Symbols", - description: "NEW button", - unicode_version: "6.0", - }, - { - emoji: "🆖", - aliases: ["ng"], - tags: [], - category: "Symbols", - description: "NG button", - unicode_version: "6.0", - }, - { - emoji: "🅾️", - aliases: ["o2"], - tags: [], - category: "Symbols", - description: "O button (blood type)", - unicode_version: "6.0", - }, - { - emoji: "🆗", - aliases: ["ok"], - tags: ["yes"], - category: "Symbols", - description: "OK button", - unicode_version: "6.0", - }, - { - emoji: "🅿️", - aliases: ["parking"], - tags: [], - category: "Symbols", - description: "P button", - unicode_version: "5.2", - }, - { - emoji: "🆘", - aliases: ["sos"], - tags: ["help", "emergency"], - category: "Symbols", - description: "SOS button", - unicode_version: "6.0", - }, - { - emoji: "🆙", - aliases: ["up"], - tags: [], - category: "Symbols", - description: "UP! button", - unicode_version: "6.0", - }, - { - emoji: "🆚", - aliases: ["vs"], - tags: [], - category: "Symbols", - description: "VS button", - unicode_version: "6.0", - }, - { - emoji: "🈁", - aliases: ["koko"], - tags: [], - category: "Symbols", - description: "Japanese “here” button", - unicode_version: "6.0", - }, - { - emoji: "🈂️", - aliases: ["sa"], - tags: [], - category: "Symbols", - description: "Japanese “service charge” button", - unicode_version: "6.0", - }, - { - emoji: "🈷️", - aliases: ["u6708"], - tags: [], - category: "Symbols", - description: "Japanese “monthly amount” button", - unicode_version: "6.0", - }, - { - emoji: "🈶", - aliases: ["u6709"], - tags: [], - category: "Symbols", - description: "Japanese “not free of charge” button", - unicode_version: "6.0", - }, - { - emoji: "🈯", - aliases: ["u6307"], - tags: [], - category: "Symbols", - description: "Japanese “reserved” button", - unicode_version: "", - }, - { - emoji: "🉐", - aliases: ["ideograph_advantage"], - tags: [], - category: "Symbols", - description: "Japanese “bargain” button", - unicode_version: "6.0", - }, - { - emoji: "🈹", - aliases: ["u5272"], - tags: [], - category: "Symbols", - description: "Japanese “discount” button", - unicode_version: "6.0", - }, - { - emoji: "🈚", - aliases: ["u7121"], - tags: [], - category: "Symbols", - description: "Japanese “free of charge” button", - unicode_version: "", - }, - { - emoji: "🈲", - aliases: ["u7981"], - tags: [], - category: "Symbols", - description: "Japanese “prohibited” button", - unicode_version: "6.0", - }, - { - emoji: "🉑", - aliases: ["accept"], - tags: [], - category: "Symbols", - description: "Japanese “acceptable” button", - unicode_version: "6.0", - }, - { - emoji: "🈸", - aliases: ["u7533"], - tags: [], - category: "Symbols", - description: "Japanese “application” button", - unicode_version: "6.0", - }, - { - emoji: "🈴", - aliases: ["u5408"], - tags: [], - category: "Symbols", - description: "Japanese “passing grade” button", - unicode_version: "6.0", - }, - { - emoji: "🈳", - aliases: ["u7a7a"], - tags: [], - category: "Symbols", - description: "Japanese “vacancy” button", - unicode_version: "6.0", - }, - { - emoji: "㊗️", - aliases: ["congratulations"], - tags: [], - category: "Symbols", - description: "Japanese “congratulations” button", - unicode_version: "", - }, - { - emoji: "㊙️", - aliases: ["secret"], - tags: [], - category: "Symbols", - description: "Japanese “secret” button", - unicode_version: "", - }, - { - emoji: "🈺", - aliases: ["u55b6"], - tags: [], - category: "Symbols", - description: "Japanese “open for business” button", - unicode_version: "6.0", - }, - { - emoji: "🈵", - aliases: ["u6e80"], - tags: [], - category: "Symbols", - description: "Japanese “no vacancy” button", - unicode_version: "6.0", - }, - { - emoji: "🔴", - aliases: ["red_circle"], - tags: [], - category: "Symbols", - description: "red circle", - unicode_version: "6.0", - }, - { - emoji: "🟠", - aliases: ["orange_circle"], - tags: [], - category: "Symbols", - description: "orange circle", - unicode_version: "12.0", - }, - { - emoji: "🟡", - aliases: ["yellow_circle"], - tags: [], - category: "Symbols", - description: "yellow circle", - unicode_version: "12.0", - }, - { - emoji: "🟢", - aliases: ["green_circle"], - tags: [], - category: "Symbols", - description: "green circle", - unicode_version: "12.0", - }, - { - emoji: "🔵", - aliases: ["large_blue_circle"], - tags: [], - category: "Symbols", - description: "blue circle", - unicode_version: "6.0", - }, - { - emoji: "🟣", - aliases: ["purple_circle"], - tags: [], - category: "Symbols", - description: "purple circle", - unicode_version: "12.0", - }, - { - emoji: "🟤", - aliases: ["brown_circle"], - tags: [], - category: "Symbols", - description: "brown circle", - unicode_version: "12.0", - }, - { - emoji: "⚫", - aliases: ["black_circle"], - tags: [], - category: "Symbols", - description: "black circle", - unicode_version: "4.1", - }, - { - emoji: "⚪", - aliases: ["white_circle"], - tags: [], - category: "Symbols", - description: "white circle", - unicode_version: "4.1", - }, - { - emoji: "🟥", - aliases: ["red_square"], - tags: [], - category: "Symbols", - description: "red square", - unicode_version: "12.0", - }, - { - emoji: "🟧", - aliases: ["orange_square"], - tags: [], - category: "Symbols", - description: "orange square", - unicode_version: "12.0", - }, - { - emoji: "🟨", - aliases: ["yellow_square"], - tags: [], - category: "Symbols", - description: "yellow square", - unicode_version: "12.0", - }, - { - emoji: "🟩", - aliases: ["green_square"], - tags: [], - category: "Symbols", - description: "green square", - unicode_version: "12.0", - }, - { - emoji: "🟦", - aliases: ["blue_square"], - tags: [], - category: "Symbols", - description: "blue square", - unicode_version: "12.0", - }, - { - emoji: "🟪", - aliases: ["purple_square"], - tags: [], - category: "Symbols", - description: "purple square", - unicode_version: "12.0", - }, - { - emoji: "🟫", - aliases: ["brown_square"], - tags: [], - category: "Symbols", - description: "brown square", - unicode_version: "12.0", - }, - { - emoji: "⬛", - aliases: ["black_large_square"], - tags: [], - category: "Symbols", - description: "black large square", - unicode_version: "5.1", - }, - { - emoji: "⬜", - aliases: ["white_large_square"], - tags: [], - category: "Symbols", - description: "white large square", - unicode_version: "5.1", - }, - { - emoji: "◼️", - aliases: ["black_medium_square"], - tags: [], - category: "Symbols", - description: "black medium square", - unicode_version: "3.2", - }, - { - emoji: "◻️", - aliases: ["white_medium_square"], - tags: [], - category: "Symbols", - description: "white medium square", - unicode_version: "3.2", - }, - { - emoji: "◾", - aliases: ["black_medium_small_square"], - tags: [], - category: "Symbols", - description: "black medium-small square", - unicode_version: "3.2", - }, - { - emoji: "◽", - aliases: ["white_medium_small_square"], - tags: [], - category: "Symbols", - description: "white medium-small square", - unicode_version: "3.2", - }, - { - emoji: "▪️", - aliases: ["black_small_square"], - tags: [], - category: "Symbols", - description: "black small square", - unicode_version: "", - }, - { - emoji: "▫️", - aliases: ["white_small_square"], - tags: [], - category: "Symbols", - description: "white small square", - unicode_version: "", - }, - { - emoji: "🔶", - aliases: ["large_orange_diamond"], - tags: [], - category: "Symbols", - description: "large orange diamond", - unicode_version: "6.0", - }, - { - emoji: "🔷", - aliases: ["large_blue_diamond"], - tags: [], - category: "Symbols", - description: "large blue diamond", - unicode_version: "6.0", - }, - { - emoji: "🔸", - aliases: ["small_orange_diamond"], - tags: [], - category: "Symbols", - description: "small orange diamond", - unicode_version: "6.0", - }, - { - emoji: "🔹", - aliases: ["small_blue_diamond"], - tags: [], - category: "Symbols", - description: "small blue diamond", - unicode_version: "6.0", - }, - { - emoji: "🔺", - aliases: ["small_red_triangle"], - tags: [], - category: "Symbols", - description: "red triangle pointed up", - unicode_version: "6.0", - }, - { - emoji: "🔻", - aliases: ["small_red_triangle_down"], - tags: [], - category: "Symbols", - description: "red triangle pointed down", - unicode_version: "6.0", - }, - { - emoji: "💠", - aliases: ["diamond_shape_with_a_dot_inside"], - tags: [], - category: "Symbols", - description: "diamond with a dot", - unicode_version: "6.0", - }, - { - emoji: "🔘", - aliases: ["radio_button"], - tags: [], - category: "Symbols", - description: "radio button", - unicode_version: "6.0", - }, - { - emoji: "🔳", - aliases: ["white_square_button"], - tags: [], - category: "Symbols", - description: "white square button", - unicode_version: "6.0", - }, - { - emoji: "🔲", - aliases: ["black_square_button"], - tags: [], - category: "Symbols", - description: "black square button", - unicode_version: "6.0", - }, - { - emoji: "🏁", - aliases: ["checkered_flag"], - tags: ["milestone", "finish"], - category: "Flags", - description: "chequered flag", - unicode_version: "6.0", - }, - { - emoji: "🚩", - aliases: ["triangular_flag_on_post"], - tags: [], - category: "Flags", - description: "triangular flag", - unicode_version: "6.0", - }, - { - emoji: "🎌", - aliases: ["crossed_flags"], - tags: [], - category: "Flags", - description: "crossed flags", - unicode_version: "6.0", - }, - { - emoji: "🏴", - aliases: ["black_flag"], - tags: [], - category: "Flags", - description: "black flag", - unicode_version: "7.0", - }, - { - emoji: "🏳️", - aliases: ["white_flag"], - tags: [], - category: "Flags", - description: "white flag", - unicode_version: "7.0", - }, - { - emoji: "🏳️‍🌈", - aliases: ["rainbow_flag"], - tags: ["pride"], - category: "Flags", - description: "rainbow flag", - unicode_version: "6.0", - }, - { - emoji: "🏳️‍⚧️", - aliases: ["transgender_flag"], - tags: [], - category: "Flags", - description: "transgender flag", - unicode_version: "13.0", - }, - { - emoji: "🏴‍☠️", - aliases: ["pirate_flag"], - tags: [], - category: "Flags", - description: "pirate flag", - unicode_version: "11.0", - }, - { - emoji: "🇦🇨", - aliases: ["ascension_island"], - tags: [], - category: "Flags", - description: "flag: Ascension Island", - unicode_version: "11.0", - }, - { - emoji: "🇦🇩", - aliases: ["andorra"], - tags: [], - category: "Flags", - description: "flag: Andorra", - unicode_version: "6.0", - }, - { - emoji: "🇦🇪", - aliases: ["united_arab_emirates"], - tags: [], - category: "Flags", - description: "flag: United Arab Emirates", - unicode_version: "6.0", - }, - { - emoji: "🇦🇫", - aliases: ["afghanistan"], - tags: [], - category: "Flags", - description: "flag: Afghanistan", - unicode_version: "6.0", - }, - { - emoji: "🇦🇬", - aliases: ["antigua_barbuda"], - tags: [], - category: "Flags", - description: "flag: Antigua & Barbuda", - unicode_version: "6.0", - }, - { - emoji: "🇦🇮", - aliases: ["anguilla"], - tags: [], - category: "Flags", - description: "flag: Anguilla", - unicode_version: "6.0", - }, - { - emoji: "🇦🇱", - aliases: ["albania"], - tags: [], - category: "Flags", - description: "flag: Albania", - unicode_version: "6.0", - }, - { - emoji: "🇦🇲", - aliases: ["armenia"], - tags: [], - category: "Flags", - description: "flag: Armenia", - unicode_version: "6.0", - }, - { - emoji: "🇦🇴", - aliases: ["angola"], - tags: [], - category: "Flags", - description: "flag: Angola", - unicode_version: "6.0", - }, - { - emoji: "🇦🇶", - aliases: ["antarctica"], - tags: [], - category: "Flags", - description: "flag: Antarctica", - unicode_version: "6.0", - }, - { - emoji: "🇦🇷", - aliases: ["argentina"], - tags: [], - category: "Flags", - description: "flag: Argentina", - unicode_version: "6.0", - }, - { - emoji: "🇦🇸", - aliases: ["american_samoa"], - tags: [], - category: "Flags", - description: "flag: American Samoa", - unicode_version: "6.0", - }, - { - emoji: "🇦🇹", - aliases: ["austria"], - tags: [], - category: "Flags", - description: "flag: Austria", - unicode_version: "6.0", - }, - { - emoji: "🇦🇺", - aliases: ["australia"], - tags: [], - category: "Flags", - description: "flag: Australia", - unicode_version: "6.0", - }, - { - emoji: "🇦🇼", - aliases: ["aruba"], - tags: [], - category: "Flags", - description: "flag: Aruba", - unicode_version: "6.0", - }, - { - emoji: "🇦🇽", - aliases: ["aland_islands"], - tags: [], - category: "Flags", - description: "flag: Åland Islands", - unicode_version: "6.0", - }, - { - emoji: "🇦🇿", - aliases: ["azerbaijan"], - tags: [], - category: "Flags", - description: "flag: Azerbaijan", - unicode_version: "6.0", - }, - { - emoji: "🇧🇦", - aliases: ["bosnia_herzegovina"], - tags: [], - category: "Flags", - description: "flag: Bosnia & Herzegovina", - unicode_version: "6.0", - }, - { - emoji: "🇧🇧", - aliases: ["barbados"], - tags: [], - category: "Flags", - description: "flag: Barbados", - unicode_version: "6.0", - }, - { - emoji: "🇧🇩", - aliases: ["bangladesh"], - tags: [], - category: "Flags", - description: "flag: Bangladesh", - unicode_version: "6.0", - }, - { - emoji: "🇧🇪", - aliases: ["belgium"], - tags: [], - category: "Flags", - description: "flag: Belgium", - unicode_version: "6.0", - }, - { - emoji: "🇧🇫", - aliases: ["burkina_faso"], - tags: [], - category: "Flags", - description: "flag: Burkina Faso", - unicode_version: "6.0", - }, - { - emoji: "🇧🇬", - aliases: ["bulgaria"], - tags: [], - category: "Flags", - description: "flag: Bulgaria", - unicode_version: "6.0", - }, - { - emoji: "🇧🇭", - aliases: ["bahrain"], - tags: [], - category: "Flags", - description: "flag: Bahrain", - unicode_version: "6.0", - }, - { - emoji: "🇧🇮", - aliases: ["burundi"], - tags: [], - category: "Flags", - description: "flag: Burundi", - unicode_version: "6.0", - }, - { - emoji: "🇧🇯", - aliases: ["benin"], - tags: [], - category: "Flags", - description: "flag: Benin", - unicode_version: "6.0", - }, - { - emoji: "🇧🇱", - aliases: ["st_barthelemy"], - tags: [], - category: "Flags", - description: "flag: St. Barthélemy", - unicode_version: "6.0", - }, - { - emoji: "🇧🇲", - aliases: ["bermuda"], - tags: [], - category: "Flags", - description: "flag: Bermuda", - unicode_version: "6.0", - }, - { - emoji: "🇧🇳", - aliases: ["brunei"], - tags: [], - category: "Flags", - description: "flag: Brunei", - unicode_version: "6.0", - }, - { - emoji: "🇧🇴", - aliases: ["bolivia"], - tags: [], - category: "Flags", - description: "flag: Bolivia", - unicode_version: "6.0", - }, - { - emoji: "🇧🇶", - aliases: ["caribbean_netherlands"], - tags: [], - category: "Flags", - description: "flag: Caribbean Netherlands", - unicode_version: "6.0", - }, - { - emoji: "🇧🇷", - aliases: ["brazil"], - tags: [], - category: "Flags", - description: "flag: Brazil", - unicode_version: "6.0", - }, - { - emoji: "🇧🇸", - aliases: ["bahamas"], - tags: [], - category: "Flags", - description: "flag: Bahamas", - unicode_version: "6.0", - }, - { - emoji: "🇧🇹", - aliases: ["bhutan"], - tags: [], - category: "Flags", - description: "flag: Bhutan", - unicode_version: "6.0", - }, - { - emoji: "🇧🇻", - aliases: ["bouvet_island"], - tags: [], - category: "Flags", - description: "flag: Bouvet Island", - unicode_version: "11.0", - }, - { - emoji: "🇧🇼", - aliases: ["botswana"], - tags: [], - category: "Flags", - description: "flag: Botswana", - unicode_version: "6.0", - }, - { - emoji: "🇧🇾", - aliases: ["belarus"], - tags: [], - category: "Flags", - description: "flag: Belarus", - unicode_version: "6.0", - }, - { - emoji: "🇧🇿", - aliases: ["belize"], - tags: [], - category: "Flags", - description: "flag: Belize", - unicode_version: "6.0", - }, - { - emoji: "🇨🇦", - aliases: ["canada"], - tags: [], - category: "Flags", - description: "flag: Canada", - unicode_version: "6.0", - }, - { - emoji: "🇨🇨", - aliases: ["cocos_islands"], - tags: ["keeling"], - category: "Flags", - description: "flag: Cocos (Keeling) Islands", - unicode_version: "6.0", - }, - { - emoji: "🇨🇩", - aliases: ["congo_kinshasa"], - tags: [], - category: "Flags", - description: "flag: Congo - Kinshasa", - unicode_version: "6.0", - }, - { - emoji: "🇨🇫", - aliases: ["central_african_republic"], - tags: [], - category: "Flags", - description: "flag: Central African Republic", - unicode_version: "6.0", - }, - { - emoji: "🇨🇬", - aliases: ["congo_brazzaville"], - tags: [], - category: "Flags", - description: "flag: Congo - Brazzaville", - unicode_version: "6.0", - }, - { - emoji: "🇨🇭", - aliases: ["switzerland"], - tags: [], - category: "Flags", - description: "flag: Switzerland", - unicode_version: "6.0", - }, - { - emoji: "🇨🇮", - aliases: ["cote_divoire"], - tags: ["ivory"], - category: "Flags", - description: "flag: Côte d’Ivoire", - unicode_version: "6.0", - }, - { - emoji: "🇨🇰", - aliases: ["cook_islands"], - tags: [], - category: "Flags", - description: "flag: Cook Islands", - unicode_version: "6.0", - }, - { - emoji: "🇨🇱", - aliases: ["chile"], - tags: [], - category: "Flags", - description: "flag: Chile", - unicode_version: "6.0", - }, - { - emoji: "🇨🇲", - aliases: ["cameroon"], - tags: [], - category: "Flags", - description: "flag: Cameroon", - unicode_version: "6.0", - }, - { - emoji: "🇨🇳", - aliases: ["cn"], - tags: ["china"], - category: "Flags", - description: "flag: China", - unicode_version: "6.0", - }, - { - emoji: "🇨🇴", - aliases: ["colombia"], - tags: [], - category: "Flags", - description: "flag: Colombia", - unicode_version: "6.0", - }, - { - emoji: "🇨🇵", - aliases: ["clipperton_island"], - tags: [], - category: "Flags", - description: "flag: Clipperton Island", - unicode_version: "11.0", - }, - { - emoji: "🇨🇷", - aliases: ["costa_rica"], - tags: [], - category: "Flags", - description: "flag: Costa Rica", - unicode_version: "6.0", - }, - { - emoji: "🇨🇺", - aliases: ["cuba"], - tags: [], - category: "Flags", - description: "flag: Cuba", - unicode_version: "6.0", - }, - { - emoji: "🇨🇻", - aliases: ["cape_verde"], - tags: [], - category: "Flags", - description: "flag: Cape Verde", - unicode_version: "6.0", - }, - { - emoji: "🇨🇼", - aliases: ["curacao"], - tags: [], - category: "Flags", - description: "flag: Curaçao", - unicode_version: "6.0", - }, - { - emoji: "🇨🇽", - aliases: ["christmas_island"], - tags: [], - category: "Flags", - description: "flag: Christmas Island", - unicode_version: "6.0", - }, - { - emoji: "🇨🇾", - aliases: ["cyprus"], - tags: [], - category: "Flags", - description: "flag: Cyprus", - unicode_version: "6.0", - }, - { - emoji: "🇨🇿", - aliases: ["czech_republic"], - tags: [], - category: "Flags", - description: "flag: Czechia", - unicode_version: "6.0", - }, - { - emoji: "🇩🇪", - aliases: ["de"], - tags: ["flag", "germany"], - category: "Flags", - description: "flag: Germany", - unicode_version: "6.0", - }, - { - emoji: "🇩🇬", - aliases: ["diego_garcia"], - tags: [], - category: "Flags", - description: "flag: Diego Garcia", - unicode_version: "11.0", - }, - { - emoji: "🇩🇯", - aliases: ["djibouti"], - tags: [], - category: "Flags", - description: "flag: Djibouti", - unicode_version: "6.0", - }, - { - emoji: "🇩🇰", - aliases: ["denmark"], - tags: [], - category: "Flags", - description: "flag: Denmark", - unicode_version: "6.0", - }, - { - emoji: "🇩🇲", - aliases: ["dominica"], - tags: [], - category: "Flags", - description: "flag: Dominica", - unicode_version: "6.0", - }, - { - emoji: "🇩🇴", - aliases: ["dominican_republic"], - tags: [], - category: "Flags", - description: "flag: Dominican Republic", - unicode_version: "6.0", - }, - { - emoji: "🇩🇿", - aliases: ["algeria"], - tags: [], - category: "Flags", - description: "flag: Algeria", - unicode_version: "6.0", - }, - { - emoji: "🇪🇦", - aliases: ["ceuta_melilla"], - tags: [], - category: "Flags", - description: "flag: Ceuta & Melilla", - unicode_version: "11.0", - }, - { - emoji: "🇪🇨", - aliases: ["ecuador"], - tags: [], - category: "Flags", - description: "flag: Ecuador", - unicode_version: "6.0", - }, - { - emoji: "🇪🇪", - aliases: ["estonia"], - tags: [], - category: "Flags", - description: "flag: Estonia", - unicode_version: "6.0", - }, - { - emoji: "🇪🇬", - aliases: ["egypt"], - tags: [], - category: "Flags", - description: "flag: Egypt", - unicode_version: "6.0", - }, - { - emoji: "🇪🇭", - aliases: ["western_sahara"], - tags: [], - category: "Flags", - description: "flag: Western Sahara", - unicode_version: "6.0", - }, - { - emoji: "🇪🇷", - aliases: ["eritrea"], - tags: [], - category: "Flags", - description: "flag: Eritrea", - unicode_version: "6.0", - }, - { - emoji: "🇪🇸", - aliases: ["es"], - tags: ["spain"], - category: "Flags", - description: "flag: Spain", - unicode_version: "6.0", - }, - { - emoji: "🇪🇹", - aliases: ["ethiopia"], - tags: [], - category: "Flags", - description: "flag: Ethiopia", - unicode_version: "6.0", - }, - { - emoji: "🇪🇺", - aliases: ["eu", "european_union"], - tags: [], - category: "Flags", - description: "flag: European Union", - unicode_version: "6.0", - }, - { - emoji: "🇫🇮", - aliases: ["finland"], - tags: [], - category: "Flags", - description: "flag: Finland", - unicode_version: "6.0", - }, - { - emoji: "🇫🇯", - aliases: ["fiji"], - tags: [], - category: "Flags", - description: "flag: Fiji", - unicode_version: "6.0", - }, - { - emoji: "🇫🇰", - aliases: ["falkland_islands"], - tags: [], - category: "Flags", - description: "flag: Falkland Islands", - unicode_version: "6.0", - }, - { - emoji: "🇫🇲", - aliases: ["micronesia"], - tags: [], - category: "Flags", - description: "flag: Micronesia", - unicode_version: "6.0", - }, - { - emoji: "🇫🇴", - aliases: ["faroe_islands"], - tags: [], - category: "Flags", - description: "flag: Faroe Islands", - unicode_version: "6.0", - }, - { - emoji: "🇫🇷", - aliases: ["fr"], - tags: ["france", "french"], - category: "Flags", - description: "flag: France", - unicode_version: "6.0", - }, - { - emoji: "🇬🇦", - aliases: ["gabon"], - tags: [], - category: "Flags", - description: "flag: Gabon", - unicode_version: "6.0", - }, - { - emoji: "🇬🇧", - aliases: ["gb", "uk"], - tags: ["flag", "british"], - category: "Flags", - description: "flag: United Kingdom", - unicode_version: "6.0", - }, - { - emoji: "🇬🇩", - aliases: ["grenada"], - tags: [], - category: "Flags", - description: "flag: Grenada", - unicode_version: "6.0", - }, - { - emoji: "🇬🇪", - aliases: ["georgia"], - tags: [], - category: "Flags", - description: "flag: Georgia", - unicode_version: "6.0", - }, - { - emoji: "🇬🇫", - aliases: ["french_guiana"], - tags: [], - category: "Flags", - description: "flag: French Guiana", - unicode_version: "6.0", - }, - { - emoji: "🇬🇬", - aliases: ["guernsey"], - tags: [], - category: "Flags", - description: "flag: Guernsey", - unicode_version: "6.0", - }, - { - emoji: "🇬🇭", - aliases: ["ghana"], - tags: [], - category: "Flags", - description: "flag: Ghana", - unicode_version: "6.0", - }, - { - emoji: "🇬🇮", - aliases: ["gibraltar"], - tags: [], - category: "Flags", - description: "flag: Gibraltar", - unicode_version: "6.0", - }, - { - emoji: "🇬🇱", - aliases: ["greenland"], - tags: [], - category: "Flags", - description: "flag: Greenland", - unicode_version: "6.0", - }, - { - emoji: "🇬🇲", - aliases: ["gambia"], - tags: [], - category: "Flags", - description: "flag: Gambia", - unicode_version: "6.0", - }, - { - emoji: "🇬🇳", - aliases: ["guinea"], - tags: [], - category: "Flags", - description: "flag: Guinea", - unicode_version: "6.0", - }, - { - emoji: "🇬🇵", - aliases: ["guadeloupe"], - tags: [], - category: "Flags", - description: "flag: Guadeloupe", - unicode_version: "6.0", - }, - { - emoji: "🇬🇶", - aliases: ["equatorial_guinea"], - tags: [], - category: "Flags", - description: "flag: Equatorial Guinea", - unicode_version: "6.0", - }, - { - emoji: "🇬🇷", - aliases: ["greece"], - tags: [], - category: "Flags", - description: "flag: Greece", - unicode_version: "6.0", - }, - { - emoji: "🇬🇸", - aliases: ["south_georgia_south_sandwich_islands"], - tags: [], - category: "Flags", - description: "flag: South Georgia & South Sandwich Islands", - unicode_version: "6.0", - }, - { - emoji: "🇬🇹", - aliases: ["guatemala"], - tags: [], - category: "Flags", - description: "flag: Guatemala", - unicode_version: "6.0", - }, - { - emoji: "🇬🇺", - aliases: ["guam"], - tags: [], - category: "Flags", - description: "flag: Guam", - unicode_version: "6.0", - }, - { - emoji: "🇬🇼", - aliases: ["guinea_bissau"], - tags: [], - category: "Flags", - description: "flag: Guinea-Bissau", - unicode_version: "6.0", - }, - { - emoji: "🇬🇾", - aliases: ["guyana"], - tags: [], - category: "Flags", - description: "flag: Guyana", - unicode_version: "6.0", - }, - { - emoji: "🇭🇰", - aliases: ["hong_kong"], - tags: [], - category: "Flags", - description: "flag: Hong Kong SAR China", - unicode_version: "6.0", - }, - { - emoji: "🇭🇲", - aliases: ["heard_mcdonald_islands"], - tags: [], - category: "Flags", - description: "flag: Heard & McDonald Islands", - unicode_version: "11.0", - }, - { - emoji: "🇭🇳", - aliases: ["honduras"], - tags: [], - category: "Flags", - description: "flag: Honduras", - unicode_version: "6.0", - }, - { - emoji: "🇭🇷", - aliases: ["croatia"], - tags: [], - category: "Flags", - description: "flag: Croatia", - unicode_version: "6.0", - }, - { - emoji: "🇭🇹", - aliases: ["haiti"], - tags: [], - category: "Flags", - description: "flag: Haiti", - unicode_version: "6.0", - }, - { - emoji: "🇭🇺", - aliases: ["hungary"], - tags: [], - category: "Flags", - description: "flag: Hungary", - unicode_version: "6.0", - }, - { - emoji: "🇮🇨", - aliases: ["canary_islands"], - tags: [], - category: "Flags", - description: "flag: Canary Islands", - unicode_version: "6.0", - }, - { - emoji: "🇮🇩", - aliases: ["indonesia"], - tags: [], - category: "Flags", - description: "flag: Indonesia", - unicode_version: "6.0", - }, - { - emoji: "🇮🇪", - aliases: ["ireland"], - tags: [], - category: "Flags", - description: "flag: Ireland", - unicode_version: "6.0", - }, - { - emoji: "🇮🇱", - aliases: ["israel"], - tags: [], - category: "Flags", - description: "flag: Israel", - unicode_version: "6.0", - }, - { - emoji: "🇮🇲", - aliases: ["isle_of_man"], - tags: [], - category: "Flags", - description: "flag: Isle of Man", - unicode_version: "6.0", - }, - { - emoji: "🇮🇳", - aliases: ["india"], - tags: [], - category: "Flags", - description: "flag: India", - unicode_version: "6.0", - }, - { - emoji: "🇮🇴", - aliases: ["british_indian_ocean_territory"], - tags: [], - category: "Flags", - description: "flag: British Indian Ocean Territory", - unicode_version: "6.0", - }, - { - emoji: "🇮🇶", - aliases: ["iraq"], - tags: [], - category: "Flags", - description: "flag: Iraq", - unicode_version: "6.0", - }, - { - emoji: "🇮🇷", - aliases: ["iran"], - tags: [], - category: "Flags", - description: "flag: Iran", - unicode_version: "6.0", - }, - { - emoji: "🇮🇸", - aliases: ["iceland"], - tags: [], - category: "Flags", - description: "flag: Iceland", - unicode_version: "6.0", - }, - { - emoji: "🇮🇹", - aliases: ["it"], - tags: ["italy"], - category: "Flags", - description: "flag: Italy", - unicode_version: "6.0", - }, - { - emoji: "🇯🇪", - aliases: ["jersey"], - tags: [], - category: "Flags", - description: "flag: Jersey", - unicode_version: "6.0", - }, - { - emoji: "🇯🇲", - aliases: ["jamaica"], - tags: [], - category: "Flags", - description: "flag: Jamaica", - unicode_version: "6.0", - }, - { - emoji: "🇯🇴", - aliases: ["jordan"], - tags: [], - category: "Flags", - description: "flag: Jordan", - unicode_version: "6.0", - }, - { - emoji: "🇯🇵", - aliases: ["jp"], - tags: ["japan"], - category: "Flags", - description: "flag: Japan", - unicode_version: "6.0", - }, - { - emoji: "🇰🇪", - aliases: ["kenya"], - tags: [], - category: "Flags", - description: "flag: Kenya", - unicode_version: "6.0", - }, - { - emoji: "🇰🇬", - aliases: ["kyrgyzstan"], - tags: [], - category: "Flags", - description: "flag: Kyrgyzstan", - unicode_version: "6.0", - }, - { - emoji: "🇰🇭", - aliases: ["cambodia"], - tags: [], - category: "Flags", - description: "flag: Cambodia", - unicode_version: "6.0", - }, - { - emoji: "🇰🇮", - aliases: ["kiribati"], - tags: [], - category: "Flags", - description: "flag: Kiribati", - unicode_version: "6.0", - }, - { - emoji: "🇰🇲", - aliases: ["comoros"], - tags: [], - category: "Flags", - description: "flag: Comoros", - unicode_version: "6.0", - }, - { - emoji: "🇰🇳", - aliases: ["st_kitts_nevis"], - tags: [], - category: "Flags", - description: "flag: St. Kitts & Nevis", - unicode_version: "6.0", - }, - { - emoji: "🇰🇵", - aliases: ["north_korea"], - tags: [], - category: "Flags", - description: "flag: North Korea", - unicode_version: "6.0", - }, - { - emoji: "🇰🇷", - aliases: ["kr"], - tags: ["korea"], - category: "Flags", - description: "flag: South Korea", - unicode_version: "6.0", - }, - { - emoji: "🇰🇼", - aliases: ["kuwait"], - tags: [], - category: "Flags", - description: "flag: Kuwait", - unicode_version: "6.0", - }, - { - emoji: "🇰🇾", - aliases: ["cayman_islands"], - tags: [], - category: "Flags", - description: "flag: Cayman Islands", - unicode_version: "6.0", - }, - { - emoji: "🇰🇿", - aliases: ["kazakhstan"], - tags: [], - category: "Flags", - description: "flag: Kazakhstan", - unicode_version: "6.0", - }, - { - emoji: "🇱🇦", - aliases: ["laos"], - tags: [], - category: "Flags", - description: "flag: Laos", - unicode_version: "6.0", - }, - { - emoji: "🇱🇧", - aliases: ["lebanon"], - tags: [], - category: "Flags", - description: "flag: Lebanon", - unicode_version: "6.0", - }, - { - emoji: "🇱🇨", - aliases: ["st_lucia"], - tags: [], - category: "Flags", - description: "flag: St. Lucia", - unicode_version: "6.0", - }, - { - emoji: "🇱🇮", - aliases: ["liechtenstein"], - tags: [], - category: "Flags", - description: "flag: Liechtenstein", - unicode_version: "6.0", - }, - { - emoji: "🇱🇰", - aliases: ["sri_lanka"], - tags: [], - category: "Flags", - description: "flag: Sri Lanka", - unicode_version: "6.0", - }, - { - emoji: "🇱🇷", - aliases: ["liberia"], - tags: [], - category: "Flags", - description: "flag: Liberia", - unicode_version: "6.0", - }, - { - emoji: "🇱🇸", - aliases: ["lesotho"], - tags: [], - category: "Flags", - description: "flag: Lesotho", - unicode_version: "6.0", - }, - { - emoji: "🇱🇹", - aliases: ["lithuania"], - tags: [], - category: "Flags", - description: "flag: Lithuania", - unicode_version: "6.0", - }, - { - emoji: "🇱🇺", - aliases: ["luxembourg"], - tags: [], - category: "Flags", - description: "flag: Luxembourg", - unicode_version: "6.0", - }, - { - emoji: "🇱🇻", - aliases: ["latvia"], - tags: [], - category: "Flags", - description: "flag: Latvia", - unicode_version: "6.0", - }, - { - emoji: "🇱🇾", - aliases: ["libya"], - tags: [], - category: "Flags", - description: "flag: Libya", - unicode_version: "6.0", - }, - { - emoji: "🇲🇦", - aliases: ["morocco"], - tags: [], - category: "Flags", - description: "flag: Morocco", - unicode_version: "6.0", - }, - { - emoji: "🇲🇨", - aliases: ["monaco"], - tags: [], - category: "Flags", - description: "flag: Monaco", - unicode_version: "6.0", - }, - { - emoji: "🇲🇩", - aliases: ["moldova"], - tags: [], - category: "Flags", - description: "flag: Moldova", - unicode_version: "6.0", - }, - { - emoji: "🇲🇪", - aliases: ["montenegro"], - tags: [], - category: "Flags", - description: "flag: Montenegro", - unicode_version: "6.0", - }, - { - emoji: "🇲🇫", - aliases: ["st_martin"], - tags: [], - category: "Flags", - description: "flag: St. Martin", - unicode_version: "11.0", - }, - { - emoji: "🇲🇬", - aliases: ["madagascar"], - tags: [], - category: "Flags", - description: "flag: Madagascar", - unicode_version: "6.0", - }, - { - emoji: "🇲🇭", - aliases: ["marshall_islands"], - tags: [], - category: "Flags", - description: "flag: Marshall Islands", - unicode_version: "6.0", - }, - { - emoji: "🇲🇰", - aliases: ["macedonia"], - tags: [], - category: "Flags", - description: "flag: North Macedonia", - unicode_version: "6.0", - }, - { - emoji: "🇲🇱", - aliases: ["mali"], - tags: [], - category: "Flags", - description: "flag: Mali", - unicode_version: "6.0", - }, - { - emoji: "🇲🇲", - aliases: ["myanmar"], - tags: ["burma"], - category: "Flags", - description: "flag: Myanmar (Burma)", - unicode_version: "6.0", - }, - { - emoji: "🇲🇳", - aliases: ["mongolia"], - tags: [], - category: "Flags", - description: "flag: Mongolia", - unicode_version: "6.0", - }, - { - emoji: "🇲🇴", - aliases: ["macau"], - tags: [], - category: "Flags", - description: "flag: Macao SAR China", - unicode_version: "6.0", - }, - { - emoji: "🇲🇵", - aliases: ["northern_mariana_islands"], - tags: [], - category: "Flags", - description: "flag: Northern Mariana Islands", - unicode_version: "6.0", - }, - { - emoji: "🇲🇶", - aliases: ["martinique"], - tags: [], - category: "Flags", - description: "flag: Martinique", - unicode_version: "6.0", - }, - { - emoji: "🇲🇷", - aliases: ["mauritania"], - tags: [], - category: "Flags", - description: "flag: Mauritania", - unicode_version: "6.0", - }, - { - emoji: "🇲🇸", - aliases: ["montserrat"], - tags: [], - category: "Flags", - description: "flag: Montserrat", - unicode_version: "6.0", - }, - { - emoji: "🇲🇹", - aliases: ["malta"], - tags: [], - category: "Flags", - description: "flag: Malta", - unicode_version: "6.0", - }, - { - emoji: "🇲🇺", - aliases: ["mauritius"], - tags: [], - category: "Flags", - description: "flag: Mauritius", - unicode_version: "6.0", - }, - { - emoji: "🇲🇻", - aliases: ["maldives"], - tags: [], - category: "Flags", - description: "flag: Maldives", - unicode_version: "6.0", - }, - { - emoji: "🇲🇼", - aliases: ["malawi"], - tags: [], - category: "Flags", - description: "flag: Malawi", - unicode_version: "6.0", - }, - { - emoji: "🇲🇽", - aliases: ["mexico"], - tags: [], - category: "Flags", - description: "flag: Mexico", - unicode_version: "6.0", - }, - { - emoji: "🇲🇾", - aliases: ["malaysia"], - tags: [], - category: "Flags", - description: "flag: Malaysia", - unicode_version: "6.0", - }, - { - emoji: "🇲🇿", - aliases: ["mozambique"], - tags: [], - category: "Flags", - description: "flag: Mozambique", - unicode_version: "6.0", - }, - { - emoji: "🇳🇦", - aliases: ["namibia"], - tags: [], - category: "Flags", - description: "flag: Namibia", - unicode_version: "6.0", - }, - { - emoji: "🇳🇨", - aliases: ["new_caledonia"], - tags: [], - category: "Flags", - description: "flag: New Caledonia", - unicode_version: "6.0", - }, - { - emoji: "🇳🇪", - aliases: ["niger"], - tags: [], - category: "Flags", - description: "flag: Niger", - unicode_version: "6.0", - }, - { - emoji: "🇳🇫", - aliases: ["norfolk_island"], - tags: [], - category: "Flags", - description: "flag: Norfolk Island", - unicode_version: "6.0", - }, - { - emoji: "🇳🇬", - aliases: ["nigeria"], - tags: [], - category: "Flags", - description: "flag: Nigeria", - unicode_version: "6.0", - }, - { - emoji: "🇳🇮", - aliases: ["nicaragua"], - tags: [], - category: "Flags", - description: "flag: Nicaragua", - unicode_version: "6.0", - }, - { - emoji: "🇳🇱", - aliases: ["netherlands"], - tags: [], - category: "Flags", - description: "flag: Netherlands", - unicode_version: "6.0", - }, - { - emoji: "🇳🇴", - aliases: ["norway"], - tags: [], - category: "Flags", - description: "flag: Norway", - unicode_version: "6.0", - }, - { - emoji: "🇳🇵", - aliases: ["nepal"], - tags: [], - category: "Flags", - description: "flag: Nepal", - unicode_version: "6.0", - }, - { - emoji: "🇳🇷", - aliases: ["nauru"], - tags: [], - category: "Flags", - description: "flag: Nauru", - unicode_version: "6.0", - }, - { - emoji: "🇳🇺", - aliases: ["niue"], - tags: [], - category: "Flags", - description: "flag: Niue", - unicode_version: "6.0", - }, - { - emoji: "🇳🇿", - aliases: ["new_zealand"], - tags: [], - category: "Flags", - description: "flag: New Zealand", - unicode_version: "6.0", - }, - { - emoji: "🇴🇲", - aliases: ["oman"], - tags: [], - category: "Flags", - description: "flag: Oman", - unicode_version: "6.0", - }, - { - emoji: "🇵🇦", - aliases: ["panama"], - tags: [], - category: "Flags", - description: "flag: Panama", - unicode_version: "6.0", - }, - { - emoji: "🇵🇪", - aliases: ["peru"], - tags: [], - category: "Flags", - description: "flag: Peru", - unicode_version: "6.0", - }, - { - emoji: "🇵🇫", - aliases: ["french_polynesia"], - tags: [], - category: "Flags", - description: "flag: French Polynesia", - unicode_version: "6.0", - }, - { - emoji: "🇵🇬", - aliases: ["papua_new_guinea"], - tags: [], - category: "Flags", - description: "flag: Papua New Guinea", - unicode_version: "6.0", - }, - { - emoji: "🇵🇭", - aliases: ["philippines"], - tags: [], - category: "Flags", - description: "flag: Philippines", - unicode_version: "6.0", - }, - { - emoji: "🇵🇰", - aliases: ["pakistan"], - tags: [], - category: "Flags", - description: "flag: Pakistan", - unicode_version: "6.0", - }, - { - emoji: "🇵🇱", - aliases: ["poland"], - tags: [], - category: "Flags", - description: "flag: Poland", - unicode_version: "6.0", - }, - { - emoji: "🇵🇲", - aliases: ["st_pierre_miquelon"], - tags: [], - category: "Flags", - description: "flag: St. Pierre & Miquelon", - unicode_version: "6.0", - }, - { - emoji: "🇵🇳", - aliases: ["pitcairn_islands"], - tags: [], - category: "Flags", - description: "flag: Pitcairn Islands", - unicode_version: "6.0", - }, - { - emoji: "🇵🇷", - aliases: ["puerto_rico"], - tags: [], - category: "Flags", - description: "flag: Puerto Rico", - unicode_version: "6.0", - }, - { - emoji: "🇵🇸", - aliases: ["palestinian_territories"], - tags: [], - category: "Flags", - description: "flag: Palestinian Territories", - unicode_version: "6.0", - }, - { - emoji: "🇵🇹", - aliases: ["portugal"], - tags: [], - category: "Flags", - description: "flag: Portugal", - unicode_version: "6.0", - }, - { - emoji: "🇵🇼", - aliases: ["palau"], - tags: [], - category: "Flags", - description: "flag: Palau", - unicode_version: "6.0", - }, - { - emoji: "🇵🇾", - aliases: ["paraguay"], - tags: [], - category: "Flags", - description: "flag: Paraguay", - unicode_version: "6.0", - }, - { - emoji: "🇶🇦", - aliases: ["qatar"], - tags: [], - category: "Flags", - description: "flag: Qatar", - unicode_version: "6.0", - }, - { - emoji: "🇷🇪", - aliases: ["reunion"], - tags: [], - category: "Flags", - description: "flag: Réunion", - unicode_version: "6.0", - }, - { - emoji: "🇷🇴", - aliases: ["romania"], - tags: [], - category: "Flags", - description: "flag: Romania", - unicode_version: "6.0", - }, - { - emoji: "🇷🇸", - aliases: ["serbia"], - tags: [], - category: "Flags", - description: "flag: Serbia", - unicode_version: "6.0", - }, - { - emoji: "🇷🇺", - aliases: ["ru"], - tags: ["russia"], - category: "Flags", - description: "flag: Russia", - unicode_version: "6.0", - }, - { - emoji: "🇷🇼", - aliases: ["rwanda"], - tags: [], - category: "Flags", - description: "flag: Rwanda", - unicode_version: "6.0", - }, - { - emoji: "🇸🇦", - aliases: ["saudi_arabia"], - tags: [], - category: "Flags", - description: "flag: Saudi Arabia", - unicode_version: "6.0", - }, - { - emoji: "🇸🇧", - aliases: ["solomon_islands"], - tags: [], - category: "Flags", - description: "flag: Solomon Islands", - unicode_version: "6.0", - }, - { - emoji: "🇸🇨", - aliases: ["seychelles"], - tags: [], - category: "Flags", - description: "flag: Seychelles", - unicode_version: "6.0", - }, - { - emoji: "🇸🇩", - aliases: ["sudan"], - tags: [], - category: "Flags", - description: "flag: Sudan", - unicode_version: "6.0", - }, - { - emoji: "🇸🇪", - aliases: ["sweden"], - tags: [], - category: "Flags", - description: "flag: Sweden", - unicode_version: "6.0", - }, - { - emoji: "🇸🇬", - aliases: ["singapore"], - tags: [], - category: "Flags", - description: "flag: Singapore", - unicode_version: "6.0", - }, - { - emoji: "🇸🇭", - aliases: ["st_helena"], - tags: [], - category: "Flags", - description: "flag: St. Helena", - unicode_version: "6.0", - }, - { - emoji: "🇸🇮", - aliases: ["slovenia"], - tags: [], - category: "Flags", - description: "flag: Slovenia", - unicode_version: "6.0", - }, - { - emoji: "🇸🇯", - aliases: ["svalbard_jan_mayen"], - tags: [], - category: "Flags", - description: "flag: Svalbard & Jan Mayen", - unicode_version: "11.0", - }, - { - emoji: "🇸🇰", - aliases: ["slovakia"], - tags: [], - category: "Flags", - description: "flag: Slovakia", - unicode_version: "6.0", - }, - { - emoji: "🇸🇱", - aliases: ["sierra_leone"], - tags: [], - category: "Flags", - description: "flag: Sierra Leone", - unicode_version: "6.0", - }, - { - emoji: "🇸🇲", - aliases: ["san_marino"], - tags: [], - category: "Flags", - description: "flag: San Marino", - unicode_version: "6.0", - }, - { - emoji: "🇸🇳", - aliases: ["senegal"], - tags: [], - category: "Flags", - description: "flag: Senegal", - unicode_version: "6.0", - }, - { - emoji: "🇸🇴", - aliases: ["somalia"], - tags: [], - category: "Flags", - description: "flag: Somalia", - unicode_version: "6.0", - }, - { - emoji: "🇸🇷", - aliases: ["suriname"], - tags: [], - category: "Flags", - description: "flag: Suriname", - unicode_version: "6.0", - }, - { - emoji: "🇸🇸", - aliases: ["south_sudan"], - tags: [], - category: "Flags", - description: "flag: South Sudan", - unicode_version: "6.0", - }, - { - emoji: "🇸🇹", - aliases: ["sao_tome_principe"], - tags: [], - category: "Flags", - description: "flag: São Tomé & Príncipe", - unicode_version: "6.0", - }, - { - emoji: "🇸🇻", - aliases: ["el_salvador"], - tags: [], - category: "Flags", - description: "flag: El Salvador", - unicode_version: "6.0", - }, - { - emoji: "🇸🇽", - aliases: ["sint_maarten"], - tags: [], - category: "Flags", - description: "flag: Sint Maarten", - unicode_version: "6.0", - }, - { - emoji: "🇸🇾", - aliases: ["syria"], - tags: [], - category: "Flags", - description: "flag: Syria", - unicode_version: "6.0", - }, - { - emoji: "🇸🇿", - aliases: ["swaziland"], - tags: [], - category: "Flags", - description: "flag: Eswatini", - unicode_version: "6.0", - }, - { - emoji: "🇹🇦", - aliases: ["tristan_da_cunha"], - tags: [], - category: "Flags", - description: "flag: Tristan da Cunha", - unicode_version: "11.0", - }, - { - emoji: "🇹🇨", - aliases: ["turks_caicos_islands"], - tags: [], - category: "Flags", - description: "flag: Turks & Caicos Islands", - unicode_version: "6.0", - }, - { - emoji: "🇹🇩", - aliases: ["chad"], - tags: [], - category: "Flags", - description: "flag: Chad", - unicode_version: "6.0", - }, - { - emoji: "🇹🇫", - aliases: ["french_southern_territories"], - tags: [], - category: "Flags", - description: "flag: French Southern Territories", - unicode_version: "6.0", - }, - { - emoji: "🇹🇬", - aliases: ["togo"], - tags: [], - category: "Flags", - description: "flag: Togo", - unicode_version: "6.0", - }, - { - emoji: "🇹🇭", - aliases: ["thailand"], - tags: [], - category: "Flags", - description: "flag: Thailand", - unicode_version: "6.0", - }, - { - emoji: "🇹🇯", - aliases: ["tajikistan"], - tags: [], - category: "Flags", - description: "flag: Tajikistan", - unicode_version: "6.0", - }, - { - emoji: "🇹🇰", - aliases: ["tokelau"], - tags: [], - category: "Flags", - description: "flag: Tokelau", - unicode_version: "6.0", - }, - { - emoji: "🇹🇱", - aliases: ["timor_leste"], - tags: [], - category: "Flags", - description: "flag: Timor-Leste", - unicode_version: "6.0", - }, - { - emoji: "🇹🇲", - aliases: ["turkmenistan"], - tags: [], - category: "Flags", - description: "flag: Turkmenistan", - unicode_version: "6.0", - }, - { - emoji: "🇹🇳", - aliases: ["tunisia"], - tags: [], - category: "Flags", - description: "flag: Tunisia", - unicode_version: "6.0", - }, - { - emoji: "🇹🇴", - aliases: ["tonga"], - tags: [], - category: "Flags", - description: "flag: Tonga", - unicode_version: "6.0", - }, - { - emoji: "🇹🇷", - aliases: ["tr"], - tags: ["turkey"], - category: "Flags", - description: "flag: Turkey", - unicode_version: "8.0", - }, - { - emoji: "🇹🇹", - aliases: ["trinidad_tobago"], - tags: [], - category: "Flags", - description: "flag: Trinidad & Tobago", - unicode_version: "6.0", - }, - { - emoji: "🇹🇻", - aliases: ["tuvalu"], - tags: [], - category: "Flags", - description: "flag: Tuvalu", - unicode_version: "6.0", - }, - { - emoji: "🇹🇼", - aliases: ["taiwan"], - tags: [], - category: "Flags", - description: "flag: Taiwan", - unicode_version: "6.0", - }, - { - emoji: "🇹🇿", - aliases: ["tanzania"], - tags: [], - category: "Flags", - description: "flag: Tanzania", - unicode_version: "6.0", - }, - { - emoji: "🇺🇦", - aliases: ["ukraine"], - tags: [], - category: "Flags", - description: "flag: Ukraine", - unicode_version: "6.0", - }, - { - emoji: "🇺🇬", - aliases: ["uganda"], - tags: [], - category: "Flags", - description: "flag: Uganda", - unicode_version: "6.0", - }, - { - emoji: "🇺🇲", - aliases: ["us_outlying_islands"], - tags: [], - category: "Flags", - description: "flag: U.S. Outlying Islands", - unicode_version: "11.0", - }, - { - emoji: "🇺🇳", - aliases: ["united_nations"], - tags: [], - category: "Flags", - description: "flag: United Nations", - unicode_version: "11.0", - }, - { - emoji: "🇺🇸", - aliases: ["us"], - tags: ["flag", "united", "america"], - category: "Flags", - description: "flag: United States", - unicode_version: "6.0", - }, - { - emoji: "🇺🇾", - aliases: ["uruguay"], - tags: [], - category: "Flags", - description: "flag: Uruguay", - unicode_version: "6.0", - }, - { - emoji: "🇺🇿", - aliases: ["uzbekistan"], - tags: [], - category: "Flags", - description: "flag: Uzbekistan", - unicode_version: "6.0", - }, - { - emoji: "🇻🇦", - aliases: ["vatican_city"], - tags: [], - category: "Flags", - description: "flag: Vatican City", - unicode_version: "6.0", - }, - { - emoji: "🇻🇨", - aliases: ["st_vincent_grenadines"], - tags: [], - category: "Flags", - description: "flag: St. Vincent & Grenadines", - unicode_version: "6.0", - }, - { - emoji: "🇻🇪", - aliases: ["venezuela"], - tags: [], - category: "Flags", - description: "flag: Venezuela", - unicode_version: "6.0", - }, - { - emoji: "🇻🇬", - aliases: ["british_virgin_islands"], - tags: [], - category: "Flags", - description: "flag: British Virgin Islands", - unicode_version: "6.0", - }, - { - emoji: "🇻🇮", - aliases: ["us_virgin_islands"], - tags: [], - category: "Flags", - description: "flag: U.S. Virgin Islands", - unicode_version: "6.0", - }, - { - emoji: "🇻🇳", - aliases: ["vietnam"], - tags: [], - category: "Flags", - description: "flag: Vietnam", - unicode_version: "6.0", - }, - { - emoji: "🇻🇺", - aliases: ["vanuatu"], - tags: [], - category: "Flags", - description: "flag: Vanuatu", - unicode_version: "6.0", - }, - { - emoji: "🇼🇫", - aliases: ["wallis_futuna"], - tags: [], - category: "Flags", - description: "flag: Wallis & Futuna", - unicode_version: "6.0", - }, - { - emoji: "🇼🇸", - aliases: ["samoa"], - tags: [], - category: "Flags", - description: "flag: Samoa", - unicode_version: "6.0", - }, - { - emoji: "🇽🇰", - aliases: ["kosovo"], - tags: [], - category: "Flags", - description: "flag: Kosovo", - unicode_version: "6.0", - }, - { - emoji: "🇾🇪", - aliases: ["yemen"], - tags: [], - category: "Flags", - description: "flag: Yemen", - unicode_version: "6.0", - }, - { - emoji: "🇾🇹", - aliases: ["mayotte"], - tags: [], - category: "Flags", - description: "flag: Mayotte", - unicode_version: "6.0", - }, - { - emoji: "🇿🇦", - aliases: ["south_africa"], - tags: [], - category: "Flags", - description: "flag: South Africa", - unicode_version: "6.0", - }, - { - emoji: "🇿🇲", - aliases: ["zambia"], - tags: [], - category: "Flags", - description: "flag: Zambia", - unicode_version: "6.0", - }, - { - emoji: "🇿🇼", - aliases: ["zimbabwe"], - tags: [], - category: "Flags", - description: "flag: Zimbabwe", - unicode_version: "6.0", - }, - { - emoji: "🏴󠁧󠁢󠁥󠁮󠁧󠁿", - aliases: ["england"], - tags: [], - category: "Flags", - description: "flag: England", - unicode_version: "11.0", - }, - { - emoji: "🏴󠁧󠁢󠁳󠁣󠁴󠁿", - aliases: ["scotland"], - tags: [], - category: "Flags", - description: "flag: Scotland", - unicode_version: "11.0", - }, - { - emoji: "🏴󠁧󠁢󠁷󠁬󠁳󠁿", - aliases: ["wales"], - tags: [], - category: "Flags", - description: "flag: Wales", - unicode_version: "11.0", - }, -]; +export const rawEmojis = [{"emoji":"😀","aliases":["grinning"],"tags":["smile","happy"],"category":"Smileys & Emotion","description":"grinning face","unicode_version":"6.1"},{"emoji":"😃","aliases":["smiley"],"tags":["happy","joy","haha"],"category":"Smileys & Emotion","description":"grinning face with big eyes","unicode_version":"6.0"},{"emoji":"😄","aliases":["smile"],"tags":["happy","joy","laugh","pleased"],"category":"Smileys & Emotion","description":"grinning face with smiling eyes","unicode_version":"6.0"},{"emoji":"😁","aliases":["grin"],"tags":[],"category":"Smileys & Emotion","description":"beaming face with smiling eyes","unicode_version":"6.0"},{"emoji":"😆","aliases":["laughing","satisfied"],"tags":["happy","haha"],"category":"Smileys & Emotion","description":"grinning squinting face","unicode_version":"6.0"},{"emoji":"😅","aliases":["sweat_smile"],"tags":["hot"],"category":"Smileys & Emotion","description":"grinning face with sweat","unicode_version":"6.0"},{"emoji":"🤣","aliases":["rofl"],"tags":["lol","laughing"],"category":"Smileys & Emotion","description":"rolling on the floor laughing","unicode_version":"9.0"},{"emoji":"😂","aliases":["joy"],"tags":["tears"],"category":"Smileys & Emotion","description":"face with tears of joy","unicode_version":"6.0"},{"emoji":"🙂","aliases":["slightly_smiling_face"],"tags":[],"category":"Smileys & Emotion","description":"slightly smiling face","unicode_version":"7.0"},{"emoji":"🙃","aliases":["upside_down_face"],"tags":[],"category":"Smileys & Emotion","description":"upside-down face","unicode_version":"8.0"},{"emoji":"😉","aliases":["wink"],"tags":["flirt"],"category":"Smileys & Emotion","description":"winking face","unicode_version":"6.0"},{"emoji":"😊","aliases":["blush"],"tags":["proud"],"category":"Smileys & Emotion","description":"smiling face with smiling eyes","unicode_version":"6.0"},{"emoji":"😇","aliases":["innocent"],"tags":["angel"],"category":"Smileys & Emotion","description":"smiling face with halo","unicode_version":"6.0"},{"emoji":"🥰","aliases":["smiling_face_with_three_hearts"],"tags":["love"],"category":"Smileys & Emotion","description":"smiling face with hearts","unicode_version":"11.0"},{"emoji":"😍","aliases":["heart_eyes"],"tags":["love","crush"],"category":"Smileys & Emotion","description":"smiling face with heart-eyes","unicode_version":"6.0"},{"emoji":"🤩","aliases":["star_struck"],"tags":["eyes"],"category":"Smileys & Emotion","description":"star-struck","unicode_version":"11.0"},{"emoji":"😘","aliases":["kissing_heart"],"tags":["flirt"],"category":"Smileys & Emotion","description":"face blowing a kiss","unicode_version":"6.0"},{"emoji":"😗","aliases":["kissing"],"tags":[],"category":"Smileys & Emotion","description":"kissing face","unicode_version":"6.1"},{"emoji":"☺️","aliases":["relaxed"],"tags":["blush","pleased"],"category":"Smileys & Emotion","description":"smiling face","unicode_version":""},{"emoji":"😚","aliases":["kissing_closed_eyes"],"tags":[],"category":"Smileys & Emotion","description":"kissing face with closed eyes","unicode_version":"6.0"},{"emoji":"😙","aliases":["kissing_smiling_eyes"],"tags":[],"category":"Smileys & Emotion","description":"kissing face with smiling eyes","unicode_version":"6.1"},{"emoji":"🥲","aliases":["smiling_face_with_tear"],"tags":[],"category":"Smileys & Emotion","description":"smiling face with tear","unicode_version":"13.0"},{"emoji":"😋","aliases":["yum"],"tags":["tongue","lick"],"category":"Smileys & Emotion","description":"face savoring food","unicode_version":"6.0"},{"emoji":"😛","aliases":["stuck_out_tongue"],"tags":[],"category":"Smileys & Emotion","description":"face with tongue","unicode_version":"6.1"},{"emoji":"😜","aliases":["stuck_out_tongue_winking_eye"],"tags":["prank","silly"],"category":"Smileys & Emotion","description":"winking face with tongue","unicode_version":"6.0"},{"emoji":"🤪","aliases":["zany_face"],"tags":["goofy","wacky"],"category":"Smileys & Emotion","description":"zany face","unicode_version":"11.0"},{"emoji":"😝","aliases":["stuck_out_tongue_closed_eyes"],"tags":["prank"],"category":"Smileys & Emotion","description":"squinting face with tongue","unicode_version":"6.0"},{"emoji":"🤑","aliases":["money_mouth_face"],"tags":["rich"],"category":"Smileys & Emotion","description":"money-mouth face","unicode_version":"8.0"},{"emoji":"🤗","aliases":["hugs"],"tags":[],"category":"Smileys & Emotion","description":"hugging face","unicode_version":"8.0"},{"emoji":"🤭","aliases":["hand_over_mouth"],"tags":["quiet","whoops"],"category":"Smileys & Emotion","description":"face with hand over mouth","unicode_version":"11.0"},{"emoji":"🤫","aliases":["shushing_face"],"tags":["silence","quiet"],"category":"Smileys & Emotion","description":"shushing face","unicode_version":"11.0"},{"emoji":"🤔","aliases":["thinking"],"tags":[],"category":"Smileys & Emotion","description":"thinking face","unicode_version":"8.0"},{"emoji":"🤐","aliases":["zipper_mouth_face"],"tags":["silence","hush"],"category":"Smileys & Emotion","description":"zipper-mouth face","unicode_version":"8.0"},{"emoji":"🤨","aliases":["raised_eyebrow"],"tags":["suspicious"],"category":"Smileys & Emotion","description":"face with raised eyebrow","unicode_version":"11.0"},{"emoji":"😐","aliases":["neutral_face"],"tags":["meh"],"category":"Smileys & Emotion","description":"neutral face","unicode_version":"6.0"},{"emoji":"😑","aliases":["expressionless"],"tags":[],"category":"Smileys & Emotion","description":"expressionless face","unicode_version":"6.1"},{"emoji":"😶","aliases":["no_mouth"],"tags":["mute","silence"],"category":"Smileys & Emotion","description":"face without mouth","unicode_version":"6.0"},{"emoji":"😶‍🌫️","aliases":["face_in_clouds"],"tags":[],"category":"Smileys & Emotion","description":"face in clouds","unicode_version":"13.1"},{"emoji":"😏","aliases":["smirk"],"tags":["smug"],"category":"Smileys & Emotion","description":"smirking face","unicode_version":"6.0"},{"emoji":"😒","aliases":["unamused"],"tags":["meh"],"category":"Smileys & Emotion","description":"unamused face","unicode_version":"6.0"},{"emoji":"🙄","aliases":["roll_eyes"],"tags":[],"category":"Smileys & Emotion","description":"face with rolling eyes","unicode_version":"8.0"},{"emoji":"😬","aliases":["grimacing"],"tags":[],"category":"Smileys & Emotion","description":"grimacing face","unicode_version":"6.1"},{"emoji":"😮‍💨","aliases":["face_exhaling"],"tags":[],"category":"Smileys & Emotion","description":"face exhaling","unicode_version":"13.1"},{"emoji":"🤥","aliases":["lying_face"],"tags":["liar"],"category":"Smileys & Emotion","description":"lying face","unicode_version":"9.0"},{"emoji":"😌","aliases":["relieved"],"tags":["whew"],"category":"Smileys & Emotion","description":"relieved face","unicode_version":"6.0"},{"emoji":"😔","aliases":["pensive"],"tags":[],"category":"Smileys & Emotion","description":"pensive face","unicode_version":"6.0"},{"emoji":"😪","aliases":["sleepy"],"tags":["tired"],"category":"Smileys & Emotion","description":"sleepy face","unicode_version":"6.0"},{"emoji":"🤤","aliases":["drooling_face"],"tags":[],"category":"Smileys & Emotion","description":"drooling face","unicode_version":"9.0"},{"emoji":"😴","aliases":["sleeping"],"tags":["zzz"],"category":"Smileys & Emotion","description":"sleeping face","unicode_version":"6.1"},{"emoji":"😷","aliases":["mask"],"tags":["sick","ill"],"category":"Smileys & Emotion","description":"face with medical mask","unicode_version":"6.0"},{"emoji":"🤒","aliases":["face_with_thermometer"],"tags":["sick"],"category":"Smileys & Emotion","description":"face with thermometer","unicode_version":"8.0"},{"emoji":"🤕","aliases":["face_with_head_bandage"],"tags":["hurt"],"category":"Smileys & Emotion","description":"face with head-bandage","unicode_version":"8.0"},{"emoji":"🤢","aliases":["nauseated_face"],"tags":["sick","barf","disgusted"],"category":"Smileys & Emotion","description":"nauseated face","unicode_version":"9.0"},{"emoji":"🤮","aliases":["vomiting_face"],"tags":["barf","sick"],"category":"Smileys & Emotion","description":"face vomiting","unicode_version":"11.0"},{"emoji":"🤧","aliases":["sneezing_face"],"tags":["achoo","sick"],"category":"Smileys & Emotion","description":"sneezing face","unicode_version":"9.0"},{"emoji":"🥵","aliases":["hot_face"],"tags":["heat","sweating"],"category":"Smileys & Emotion","description":"hot face","unicode_version":"11.0"},{"emoji":"🥶","aliases":["cold_face"],"tags":["freezing","ice"],"category":"Smileys & Emotion","description":"cold face","unicode_version":"11.0"},{"emoji":"🥴","aliases":["woozy_face"],"tags":["groggy"],"category":"Smileys & Emotion","description":"woozy face","unicode_version":"11.0"},{"emoji":"😵","aliases":["dizzy_face"],"tags":[],"category":"Smileys & Emotion","description":"knocked-out face","unicode_version":"6.0"},{"emoji":"😵‍💫","aliases":["face_with_spiral_eyes"],"tags":[],"category":"Smileys & Emotion","description":"face with spiral eyes","unicode_version":"13.1"},{"emoji":"🤯","aliases":["exploding_head"],"tags":["mind","blown"],"category":"Smileys & Emotion","description":"exploding head","unicode_version":"11.0"},{"emoji":"🤠","aliases":["cowboy_hat_face"],"tags":[],"category":"Smileys & Emotion","description":"cowboy hat face","unicode_version":"9.0"},{"emoji":"🥳","aliases":["partying_face"],"tags":["celebration","birthday"],"category":"Smileys & Emotion","description":"partying face","unicode_version":"11.0"},{"emoji":"🥸","aliases":["disguised_face"],"tags":[],"category":"Smileys & Emotion","description":"disguised face","unicode_version":"13.0"},{"emoji":"😎","aliases":["sunglasses"],"tags":["cool"],"category":"Smileys & Emotion","description":"smiling face with sunglasses","unicode_version":"6.0"},{"emoji":"🤓","aliases":["nerd_face"],"tags":["geek","glasses"],"category":"Smileys & Emotion","description":"nerd face","unicode_version":"8.0"},{"emoji":"🧐","aliases":["monocle_face"],"tags":[],"category":"Smileys & Emotion","description":"face with monocle","unicode_version":"11.0"},{"emoji":"😕","aliases":["confused"],"tags":[],"category":"Smileys & Emotion","description":"confused face","unicode_version":"6.1"},{"emoji":"😟","aliases":["worried"],"tags":["nervous"],"category":"Smileys & Emotion","description":"worried face","unicode_version":"6.1"},{"emoji":"🙁","aliases":["slightly_frowning_face"],"tags":[],"category":"Smileys & Emotion","description":"slightly frowning face","unicode_version":"7.0"},{"emoji":"☹️","aliases":["frowning_face"],"tags":[],"category":"Smileys & Emotion","description":"frowning face","unicode_version":""},{"emoji":"😮","aliases":["open_mouth"],"tags":["surprise","impressed","wow"],"category":"Smileys & Emotion","description":"face with open mouth","unicode_version":"6.1"},{"emoji":"😯","aliases":["hushed"],"tags":["silence","speechless"],"category":"Smileys & Emotion","description":"hushed face","unicode_version":"6.1"},{"emoji":"😲","aliases":["astonished"],"tags":["amazed","gasp"],"category":"Smileys & Emotion","description":"astonished face","unicode_version":"6.0"},{"emoji":"😳","aliases":["flushed"],"tags":[],"category":"Smileys & Emotion","description":"flushed face","unicode_version":"6.0"},{"emoji":"🥺","aliases":["pleading_face"],"tags":["puppy","eyes"],"category":"Smileys & Emotion","description":"pleading face","unicode_version":"11.0"},{"emoji":"😦","aliases":["frowning"],"tags":[],"category":"Smileys & Emotion","description":"frowning face with open mouth","unicode_version":"6.1"},{"emoji":"😧","aliases":["anguished"],"tags":["stunned"],"category":"Smileys & Emotion","description":"anguished face","unicode_version":"6.1"},{"emoji":"😨","aliases":["fearful"],"tags":["scared","shocked","oops"],"category":"Smileys & Emotion","description":"fearful face","unicode_version":"6.0"},{"emoji":"😰","aliases":["cold_sweat"],"tags":["nervous"],"category":"Smileys & Emotion","description":"anxious face with sweat","unicode_version":"6.0"},{"emoji":"😥","aliases":["disappointed_relieved"],"tags":["phew","sweat","nervous"],"category":"Smileys & Emotion","description":"sad but relieved face","unicode_version":"6.0"},{"emoji":"😢","aliases":["cry"],"tags":["sad","tear"],"category":"Smileys & Emotion","description":"crying face","unicode_version":"6.0"},{"emoji":"😭","aliases":["sob"],"tags":["sad","cry","bawling"],"category":"Smileys & Emotion","description":"loudly crying face","unicode_version":"6.0"},{"emoji":"😱","aliases":["scream"],"tags":["horror","shocked"],"category":"Smileys & Emotion","description":"face screaming in fear","unicode_version":"6.0"},{"emoji":"😖","aliases":["confounded"],"tags":[],"category":"Smileys & Emotion","description":"confounded face","unicode_version":"6.0"},{"emoji":"😣","aliases":["persevere"],"tags":["struggling"],"category":"Smileys & Emotion","description":"persevering face","unicode_version":"6.0"},{"emoji":"😞","aliases":["disappointed"],"tags":["sad"],"category":"Smileys & Emotion","description":"disappointed face","unicode_version":"6.0"},{"emoji":"😓","aliases":["sweat"],"tags":[],"category":"Smileys & Emotion","description":"downcast face with sweat","unicode_version":"6.0"},{"emoji":"😩","aliases":["weary"],"tags":["tired"],"category":"Smileys & Emotion","description":"weary face","unicode_version":"6.0"},{"emoji":"😫","aliases":["tired_face"],"tags":["upset","whine"],"category":"Smileys & Emotion","description":"tired face","unicode_version":"6.0"},{"emoji":"🥱","aliases":["yawning_face"],"tags":[],"category":"Smileys & Emotion","description":"yawning face","unicode_version":"12.0"},{"emoji":"😤","aliases":["triumph"],"tags":["smug"],"category":"Smileys & Emotion","description":"face with steam from nose","unicode_version":"6.0"},{"emoji":"😡","aliases":["rage","pout"],"tags":["angry"],"category":"Smileys & Emotion","description":"pouting face","unicode_version":"6.0"},{"emoji":"😠","aliases":["angry"],"tags":["mad","annoyed"],"category":"Smileys & Emotion","description":"angry face","unicode_version":"6.0"},{"emoji":"🤬","aliases":["cursing_face"],"tags":["foul"],"category":"Smileys & Emotion","description":"face with symbols on mouth","unicode_version":"11.0"},{"emoji":"😈","aliases":["smiling_imp"],"tags":["devil","evil","horns"],"category":"Smileys & Emotion","description":"smiling face with horns","unicode_version":"6.0"},{"emoji":"👿","aliases":["imp"],"tags":["angry","devil","evil","horns"],"category":"Smileys & Emotion","description":"angry face with horns","unicode_version":"6.0"},{"emoji":"💀","aliases":["skull"],"tags":["dead","danger","poison"],"category":"Smileys & Emotion","description":"skull","unicode_version":"6.0"},{"emoji":"☠️","aliases":["skull_and_crossbones"],"tags":["danger","pirate"],"category":"Smileys & Emotion","description":"skull and crossbones","unicode_version":""},{"emoji":"💩","aliases":["hankey","poop","shit"],"tags":["crap"],"category":"Smileys & Emotion","description":"pile of poo","unicode_version":"6.0"},{"emoji":"🤡","aliases":["clown_face"],"tags":[],"category":"Smileys & Emotion","description":"clown face","unicode_version":"9.0"},{"emoji":"👹","aliases":["japanese_ogre"],"tags":["monster"],"category":"Smileys & Emotion","description":"ogre","unicode_version":"6.0"},{"emoji":"👺","aliases":["japanese_goblin"],"tags":[],"category":"Smileys & Emotion","description":"goblin","unicode_version":"6.0"},{"emoji":"👻","aliases":["ghost"],"tags":["halloween"],"category":"Smileys & Emotion","description":"ghost","unicode_version":"6.0"},{"emoji":"👽","aliases":["alien"],"tags":["ufo"],"category":"Smileys & Emotion","description":"alien","unicode_version":"6.0"},{"emoji":"👾","aliases":["space_invader"],"tags":["game","retro"],"category":"Smileys & Emotion","description":"alien monster","unicode_version":"6.0"},{"emoji":"🤖","aliases":["robot"],"tags":[],"category":"Smileys & Emotion","description":"robot","unicode_version":"8.0"},{"emoji":"😺","aliases":["smiley_cat"],"tags":[],"category":"Smileys & Emotion","description":"grinning cat","unicode_version":"6.0"},{"emoji":"😸","aliases":["smile_cat"],"tags":[],"category":"Smileys & Emotion","description":"grinning cat with smiling eyes","unicode_version":"6.0"},{"emoji":"😹","aliases":["joy_cat"],"tags":[],"category":"Smileys & Emotion","description":"cat with tears of joy","unicode_version":"6.0"},{"emoji":"😻","aliases":["heart_eyes_cat"],"tags":[],"category":"Smileys & Emotion","description":"smiling cat with heart-eyes","unicode_version":"6.0"},{"emoji":"😼","aliases":["smirk_cat"],"tags":[],"category":"Smileys & Emotion","description":"cat with wry smile","unicode_version":"6.0"},{"emoji":"😽","aliases":["kissing_cat"],"tags":[],"category":"Smileys & Emotion","description":"kissing cat","unicode_version":"6.0"},{"emoji":"🙀","aliases":["scream_cat"],"tags":["horror"],"category":"Smileys & Emotion","description":"weary cat","unicode_version":"6.0"},{"emoji":"😿","aliases":["crying_cat_face"],"tags":["sad","tear"],"category":"Smileys & Emotion","description":"crying cat","unicode_version":"6.0"},{"emoji":"😾","aliases":["pouting_cat"],"tags":[],"category":"Smileys & Emotion","description":"pouting cat","unicode_version":"6.0"},{"emoji":"🙈","aliases":["see_no_evil"],"tags":["monkey","blind","ignore"],"category":"Smileys & Emotion","description":"see-no-evil monkey","unicode_version":"6.0"},{"emoji":"🙉","aliases":["hear_no_evil"],"tags":["monkey","deaf"],"category":"Smileys & Emotion","description":"hear-no-evil monkey","unicode_version":"6.0"},{"emoji":"🙊","aliases":["speak_no_evil"],"tags":["monkey","mute","hush"],"category":"Smileys & Emotion","description":"speak-no-evil monkey","unicode_version":"6.0"},{"emoji":"💋","aliases":["kiss"],"tags":["lipstick"],"category":"Smileys & Emotion","description":"kiss mark","unicode_version":"6.0"},{"emoji":"💌","aliases":["love_letter"],"tags":["email","envelope"],"category":"Smileys & Emotion","description":"love letter","unicode_version":"6.0"},{"emoji":"💘","aliases":["cupid"],"tags":["love","heart"],"category":"Smileys & Emotion","description":"heart with arrow","unicode_version":"6.0"},{"emoji":"💝","aliases":["gift_heart"],"tags":["chocolates"],"category":"Smileys & Emotion","description":"heart with ribbon","unicode_version":"6.0"},{"emoji":"💖","aliases":["sparkling_heart"],"tags":[],"category":"Smileys & Emotion","description":"sparkling heart","unicode_version":"6.0"},{"emoji":"💗","aliases":["heartpulse"],"tags":[],"category":"Smileys & Emotion","description":"growing heart","unicode_version":"6.0"},{"emoji":"💓","aliases":["heartbeat"],"tags":[],"category":"Smileys & Emotion","description":"beating heart","unicode_version":"6.0"},{"emoji":"💞","aliases":["revolving_hearts"],"tags":[],"category":"Smileys & Emotion","description":"revolving hearts","unicode_version":"6.0"},{"emoji":"💕","aliases":["two_hearts"],"tags":[],"category":"Smileys & Emotion","description":"two hearts","unicode_version":"6.0"},{"emoji":"💟","aliases":["heart_decoration"],"tags":[],"category":"Smileys & Emotion","description":"heart decoration","unicode_version":"6.0"},{"emoji":"❣️","aliases":["heavy_heart_exclamation"],"tags":[],"category":"Smileys & Emotion","description":"heart exclamation","unicode_version":""},{"emoji":"💔","aliases":["broken_heart"],"tags":[],"category":"Smileys & Emotion","description":"broken heart","unicode_version":"6.0"},{"emoji":"❤️‍🔥","aliases":["heart_on_fire"],"tags":[],"category":"Smileys & Emotion","description":"heart on fire","unicode_version":"13.1"},{"emoji":"❤️‍🩹","aliases":["mending_heart"],"tags":[],"category":"Smileys & Emotion","description":"mending heart","unicode_version":"13.1"},{"emoji":"❤️","aliases":["heart"],"tags":["love"],"category":"Smileys & Emotion","description":"red heart","unicode_version":""},{"emoji":"🧡","aliases":["orange_heart"],"tags":[],"category":"Smileys & Emotion","description":"orange heart","unicode_version":"11.0"},{"emoji":"💛","aliases":["yellow_heart"],"tags":[],"category":"Smileys & Emotion","description":"yellow heart","unicode_version":"6.0"},{"emoji":"💚","aliases":["green_heart"],"tags":[],"category":"Smileys & Emotion","description":"green heart","unicode_version":"6.0"},{"emoji":"💙","aliases":["blue_heart"],"tags":[],"category":"Smileys & Emotion","description":"blue heart","unicode_version":"6.0"},{"emoji":"💜","aliases":["purple_heart"],"tags":[],"category":"Smileys & Emotion","description":"purple heart","unicode_version":"6.0"},{"emoji":"🤎","aliases":["brown_heart"],"tags":[],"category":"Smileys & Emotion","description":"brown heart","unicode_version":"12.0"},{"emoji":"🖤","aliases":["black_heart"],"tags":[],"category":"Smileys & Emotion","description":"black heart","unicode_version":"9.0"},{"emoji":"🤍","aliases":["white_heart"],"tags":[],"category":"Smileys & Emotion","description":"white heart","unicode_version":"12.0"},{"emoji":"💯","aliases":["100"],"tags":["score","perfect"],"category":"Smileys & Emotion","description":"hundred points","unicode_version":"6.0"},{"emoji":"💢","aliases":["anger"],"tags":["angry"],"category":"Smileys & Emotion","description":"anger symbol","unicode_version":"6.0"},{"emoji":"💥","aliases":["boom","collision"],"tags":["explode"],"category":"Smileys & Emotion","description":"collision","unicode_version":"6.0"},{"emoji":"💫","aliases":["dizzy"],"tags":["star"],"category":"Smileys & Emotion","description":"dizzy","unicode_version":"6.0"},{"emoji":"💦","aliases":["sweat_drops"],"tags":["water","workout"],"category":"Smileys & Emotion","description":"sweat droplets","unicode_version":"6.0"},{"emoji":"💨","aliases":["dash"],"tags":["wind","blow","fast"],"category":"Smileys & Emotion","description":"dashing away","unicode_version":"6.0"},{"emoji":"🕳️","aliases":["hole"],"tags":[],"category":"Smileys & Emotion","description":"hole","unicode_version":"7.0"},{"emoji":"💣","aliases":["bomb"],"tags":["boom"],"category":"Smileys & Emotion","description":"bomb","unicode_version":"6.0"},{"emoji":"💬","aliases":["speech_balloon"],"tags":["comment"],"category":"Smileys & Emotion","description":"speech balloon","unicode_version":"6.0"},{"emoji":"👁️‍🗨️","aliases":["eye_speech_bubble"],"tags":[],"category":"Smileys & Emotion","description":"eye in speech bubble","unicode_version":"11.0"},{"emoji":"🗨️","aliases":["left_speech_bubble"],"tags":[],"category":"Smileys & Emotion","description":"left speech bubble","unicode_version":"11.0"},{"emoji":"🗯️","aliases":["right_anger_bubble"],"tags":[],"category":"Smileys & Emotion","description":"right anger bubble","unicode_version":"7.0"},{"emoji":"💭","aliases":["thought_balloon"],"tags":["thinking"],"category":"Smileys & Emotion","description":"thought balloon","unicode_version":"6.0"},{"emoji":"💤","aliases":["zzz"],"tags":["sleeping"],"category":"Smileys & Emotion","description":"zzz","unicode_version":"6.0"},{"emoji":"👋","aliases":["wave"],"tags":["goodbye"],"category":"People & Body","description":"waving hand","unicode_version":"6.0"},{"emoji":"🤚","aliases":["raised_back_of_hand"],"tags":[],"category":"People & Body","description":"raised back of hand","unicode_version":"9.0"},{"emoji":"🖐️","aliases":["raised_hand_with_fingers_splayed"],"tags":[],"category":"People & Body","description":"hand with fingers splayed","unicode_version":"7.0"},{"emoji":"✋","aliases":["hand","raised_hand"],"tags":["highfive","stop"],"category":"People & Body","description":"raised hand","unicode_version":"6.0"},{"emoji":"🖖","aliases":["vulcan_salute"],"tags":["prosper","spock"],"category":"People & Body","description":"vulcan salute","unicode_version":"7.0"},{"emoji":"👌","aliases":["ok_hand"],"tags":[],"category":"People & Body","description":"OK hand","unicode_version":"6.0"},{"emoji":"🤌","aliases":["pinched_fingers"],"tags":[],"category":"People & Body","description":"pinched fingers","unicode_version":"13.0"},{"emoji":"🤏","aliases":["pinching_hand"],"tags":[],"category":"People & Body","description":"pinching hand","unicode_version":"12.0"},{"emoji":"✌️","aliases":["v"],"tags":["victory","peace"],"category":"People & Body","description":"victory hand","unicode_version":""},{"emoji":"🤞","aliases":["crossed_fingers"],"tags":["luck","hopeful"],"category":"People & Body","description":"crossed fingers","unicode_version":"9.0"},{"emoji":"🤟","aliases":["love_you_gesture"],"tags":[],"category":"People & Body","description":"love-you gesture","unicode_version":"11.0"},{"emoji":"🤘","aliases":["metal"],"tags":[],"category":"People & Body","description":"sign of the horns","unicode_version":"8.0"},{"emoji":"🤙","aliases":["call_me_hand"],"tags":[],"category":"People & Body","description":"call me hand","unicode_version":"9.0"},{"emoji":"👈","aliases":["point_left"],"tags":[],"category":"People & Body","description":"backhand index pointing left","unicode_version":"6.0"},{"emoji":"👉","aliases":["point_right"],"tags":[],"category":"People & Body","description":"backhand index pointing right","unicode_version":"6.0"},{"emoji":"👆","aliases":["point_up_2"],"tags":[],"category":"People & Body","description":"backhand index pointing up","unicode_version":"6.0"},{"emoji":"🖕","aliases":["middle_finger","fu"],"tags":[],"category":"People & Body","description":"middle finger","unicode_version":"7.0"},{"emoji":"👇","aliases":["point_down"],"tags":[],"category":"People & Body","description":"backhand index pointing down","unicode_version":"6.0"},{"emoji":"☝️","aliases":["point_up"],"tags":[],"category":"People & Body","description":"index pointing up","unicode_version":""},{"emoji":"👍","aliases":["+1","thumbsup"],"tags":["approve","ok"],"category":"People & Body","description":"thumbs up","unicode_version":"6.0"},{"emoji":"👎","aliases":["-1","thumbsdown"],"tags":["disapprove","bury"],"category":"People & Body","description":"thumbs down","unicode_version":"6.0"},{"emoji":"✊","aliases":["fist_raised","fist"],"tags":["power"],"category":"People & Body","description":"raised fist","unicode_version":"6.0"},{"emoji":"👊","aliases":["fist_oncoming","facepunch","punch"],"tags":["attack"],"category":"People & Body","description":"oncoming fist","unicode_version":"6.0"},{"emoji":"🤛","aliases":["fist_left"],"tags":[],"category":"People & Body","description":"left-facing fist","unicode_version":"9.0"},{"emoji":"🤜","aliases":["fist_right"],"tags":[],"category":"People & Body","description":"right-facing fist","unicode_version":"9.0"},{"emoji":"👏","aliases":["clap"],"tags":["praise","applause"],"category":"People & Body","description":"clapping hands","unicode_version":"6.0"},{"emoji":"🙌","aliases":["raised_hands"],"tags":["hooray"],"category":"People & Body","description":"raising hands","unicode_version":"6.0"},{"emoji":"👐","aliases":["open_hands"],"tags":[],"category":"People & Body","description":"open hands","unicode_version":"6.0"},{"emoji":"🤲","aliases":["palms_up_together"],"tags":[],"category":"People & Body","description":"palms up together","unicode_version":"11.0"},{"emoji":"🤝","aliases":["handshake"],"tags":["deal"],"category":"People & Body","description":"handshake","unicode_version":"9.0"},{"emoji":"🙏","aliases":["pray"],"tags":["please","hope","wish"],"category":"People & Body","description":"folded hands","unicode_version":"6.0"},{"emoji":"✍️","aliases":["writing_hand"],"tags":[],"category":"People & Body","description":"writing hand","unicode_version":""},{"emoji":"💅","aliases":["nail_care"],"tags":["beauty","manicure"],"category":"People & Body","description":"nail polish","unicode_version":"6.0"},{"emoji":"🤳","aliases":["selfie"],"tags":[],"category":"People & Body","description":"selfie","unicode_version":"9.0"},{"emoji":"💪","aliases":["muscle"],"tags":["flex","bicep","strong","workout"],"category":"People & Body","description":"flexed biceps","unicode_version":"6.0"},{"emoji":"🦾","aliases":["mechanical_arm"],"tags":[],"category":"People & Body","description":"mechanical arm","unicode_version":"12.0"},{"emoji":"🦿","aliases":["mechanical_leg"],"tags":[],"category":"People & Body","description":"mechanical leg","unicode_version":"12.0"},{"emoji":"🦵","aliases":["leg"],"tags":[],"category":"People & Body","description":"leg","unicode_version":"11.0"},{"emoji":"🦶","aliases":["foot"],"tags":[],"category":"People & Body","description":"foot","unicode_version":"11.0"},{"emoji":"👂","aliases":["ear"],"tags":["hear","sound","listen"],"category":"People & Body","description":"ear","unicode_version":"6.0"},{"emoji":"🦻","aliases":["ear_with_hearing_aid"],"tags":[],"category":"People & Body","description":"ear with hearing aid","unicode_version":"12.0"},{"emoji":"👃","aliases":["nose"],"tags":["smell"],"category":"People & Body","description":"nose","unicode_version":"6.0"},{"emoji":"🧠","aliases":["brain"],"tags":[],"category":"People & Body","description":"brain","unicode_version":"11.0"},{"emoji":"🫀","aliases":["anatomical_heart"],"tags":[],"category":"People & Body","description":"anatomical heart","unicode_version":"13.0"},{"emoji":"🫁","aliases":["lungs"],"tags":[],"category":"People & Body","description":"lungs","unicode_version":"13.0"},{"emoji":"🦷","aliases":["tooth"],"tags":[],"category":"People & Body","description":"tooth","unicode_version":"11.0"},{"emoji":"🦴","aliases":["bone"],"tags":[],"category":"People & Body","description":"bone","unicode_version":"11.0"},{"emoji":"👀","aliases":["eyes"],"tags":["look","see","watch"],"category":"People & Body","description":"eyes","unicode_version":"6.0"},{"emoji":"👁️","aliases":["eye"],"tags":[],"category":"People & Body","description":"eye","unicode_version":"7.0"},{"emoji":"👅","aliases":["tongue"],"tags":["taste"],"category":"People & Body","description":"tongue","unicode_version":"6.0"},{"emoji":"👄","aliases":["lips"],"tags":["kiss"],"category":"People & Body","description":"mouth","unicode_version":"6.0"},{"emoji":"👶","aliases":["baby"],"tags":["child","newborn"],"category":"People & Body","description":"baby","unicode_version":"6.0"},{"emoji":"🧒","aliases":["child"],"tags":[],"category":"People & Body","description":"child","unicode_version":"11.0"},{"emoji":"👦","aliases":["boy"],"tags":["child"],"category":"People & Body","description":"boy","unicode_version":"6.0"},{"emoji":"👧","aliases":["girl"],"tags":["child"],"category":"People & Body","description":"girl","unicode_version":"6.0"},{"emoji":"🧑","aliases":["adult"],"tags":[],"category":"People & Body","description":"person","unicode_version":"11.0"},{"emoji":"👱","aliases":["blond_haired_person"],"tags":[],"category":"People & Body","description":"person: blond hair","unicode_version":"6.0"},{"emoji":"👨","aliases":["man"],"tags":["mustache","father","dad"],"category":"People & Body","description":"man","unicode_version":"6.0"},{"emoji":"🧔","aliases":["bearded_person"],"tags":[],"category":"People & Body","description":"person: beard","unicode_version":"11.0"},{"emoji":"🧔‍♂️","aliases":["man_beard"],"tags":[],"category":"People & Body","description":"man: beard","unicode_version":"13.1"},{"emoji":"🧔‍♀️","aliases":["woman_beard"],"tags":[],"category":"People & Body","description":"woman: beard","unicode_version":"13.1"},{"emoji":"👨‍🦰","aliases":["red_haired_man"],"tags":[],"category":"People & Body","description":"man: red hair","unicode_version":"11.0"},{"emoji":"👨‍🦱","aliases":["curly_haired_man"],"tags":[],"category":"People & Body","description":"man: curly hair","unicode_version":"11.0"},{"emoji":"👨‍🦳","aliases":["white_haired_man"],"tags":[],"category":"People & Body","description":"man: white hair","unicode_version":"11.0"},{"emoji":"👨‍🦲","aliases":["bald_man"],"tags":[],"category":"People & Body","description":"man: bald","unicode_version":"11.0"},{"emoji":"👩","aliases":["woman"],"tags":["girls"],"category":"People & Body","description":"woman","unicode_version":"6.0"},{"emoji":"👩‍🦰","aliases":["red_haired_woman"],"tags":[],"category":"People & Body","description":"woman: red hair","unicode_version":"11.0"},{"emoji":"🧑‍🦰","aliases":["person_red_hair"],"tags":[],"category":"People & Body","description":"person: red hair","unicode_version":"12.1"},{"emoji":"👩‍🦱","aliases":["curly_haired_woman"],"tags":[],"category":"People & Body","description":"woman: curly hair","unicode_version":"11.0"},{"emoji":"🧑‍🦱","aliases":["person_curly_hair"],"tags":[],"category":"People & Body","description":"person: curly hair","unicode_version":"12.1"},{"emoji":"👩‍🦳","aliases":["white_haired_woman"],"tags":[],"category":"People & Body","description":"woman: white hair","unicode_version":"11.0"},{"emoji":"🧑‍🦳","aliases":["person_white_hair"],"tags":[],"category":"People & Body","description":"person: white hair","unicode_version":"12.1"},{"emoji":"👩‍🦲","aliases":["bald_woman"],"tags":[],"category":"People & Body","description":"woman: bald","unicode_version":"11.0"},{"emoji":"🧑‍🦲","aliases":["person_bald"],"tags":[],"category":"People & Body","description":"person: bald","unicode_version":"12.1"},{"emoji":"👱‍♀️","aliases":["blond_haired_woman","blonde_woman"],"tags":[],"category":"People & Body","description":"woman: blond hair","unicode_version":"6.0"},{"emoji":"👱‍♂️","aliases":["blond_haired_man"],"tags":[],"category":"People & Body","description":"man: blond hair","unicode_version":"11.0"},{"emoji":"🧓","aliases":["older_adult"],"tags":[],"category":"People & Body","description":"older person","unicode_version":"11.0"},{"emoji":"👴","aliases":["older_man"],"tags":[],"category":"People & Body","description":"old man","unicode_version":"6.0"},{"emoji":"👵","aliases":["older_woman"],"tags":[],"category":"People & Body","description":"old woman","unicode_version":"6.0"},{"emoji":"🙍","aliases":["frowning_person"],"tags":[],"category":"People & Body","description":"person frowning","unicode_version":"6.0"},{"emoji":"🙍‍♂️","aliases":["frowning_man"],"tags":[],"category":"People & Body","description":"man frowning","unicode_version":"6.0"},{"emoji":"🙍‍♀️","aliases":["frowning_woman"],"tags":[],"category":"People & Body","description":"woman frowning","unicode_version":"11.0"},{"emoji":"🙎","aliases":["pouting_face"],"tags":[],"category":"People & Body","description":"person pouting","unicode_version":"6.0"},{"emoji":"🙎‍♂️","aliases":["pouting_man"],"tags":[],"category":"People & Body","description":"man pouting","unicode_version":"6.0"},{"emoji":"🙎‍♀️","aliases":["pouting_woman"],"tags":[],"category":"People & Body","description":"woman pouting","unicode_version":"11.0"},{"emoji":"🙅","aliases":["no_good"],"tags":["stop","halt","denied"],"category":"People & Body","description":"person gesturing NO","unicode_version":"6.0"},{"emoji":"🙅‍♂️","aliases":["no_good_man","ng_man"],"tags":["stop","halt","denied"],"category":"People & Body","description":"man gesturing NO","unicode_version":"6.0"},{"emoji":"🙅‍♀️","aliases":["no_good_woman","ng_woman"],"tags":["stop","halt","denied"],"category":"People & Body","description":"woman gesturing NO","unicode_version":"11.0"},{"emoji":"🙆","aliases":["ok_person"],"tags":[],"category":"People & Body","description":"person gesturing OK","unicode_version":"6.0"},{"emoji":"🙆‍♂️","aliases":["ok_man"],"tags":[],"category":"People & Body","description":"man gesturing OK","unicode_version":"6.0"},{"emoji":"🙆‍♀️","aliases":["ok_woman"],"tags":[],"category":"People & Body","description":"woman gesturing OK","unicode_version":"11.0"},{"emoji":"💁","aliases":["tipping_hand_person","information_desk_person"],"tags":[],"category":"People & Body","description":"person tipping hand","unicode_version":"6.0"},{"emoji":"💁‍♂️","aliases":["tipping_hand_man","sassy_man"],"tags":["information"],"category":"People & Body","description":"man tipping hand","unicode_version":"6.0"},{"emoji":"💁‍♀️","aliases":["tipping_hand_woman","sassy_woman"],"tags":["information"],"category":"People & Body","description":"woman tipping hand","unicode_version":"11.0"},{"emoji":"🙋","aliases":["raising_hand"],"tags":[],"category":"People & Body","description":"person raising hand","unicode_version":"6.0"},{"emoji":"🙋‍♂️","aliases":["raising_hand_man"],"tags":[],"category":"People & Body","description":"man raising hand","unicode_version":"6.0"},{"emoji":"🙋‍♀️","aliases":["raising_hand_woman"],"tags":[],"category":"People & Body","description":"woman raising hand","unicode_version":"11.0"},{"emoji":"🧏","aliases":["deaf_person"],"tags":[],"category":"People & Body","description":"deaf person","unicode_version":"12.0"},{"emoji":"🧏‍♂️","aliases":["deaf_man"],"tags":[],"category":"People & Body","description":"deaf man","unicode_version":"12.0"},{"emoji":"🧏‍♀️","aliases":["deaf_woman"],"tags":[],"category":"People & Body","description":"deaf woman","unicode_version":"12.0"},{"emoji":"🙇","aliases":["bow"],"tags":["respect","thanks"],"category":"People & Body","description":"person bowing","unicode_version":"6.0"},{"emoji":"🙇‍♂️","aliases":["bowing_man"],"tags":["respect","thanks"],"category":"People & Body","description":"man bowing","unicode_version":"11.0"},{"emoji":"🙇‍♀️","aliases":["bowing_woman"],"tags":["respect","thanks"],"category":"People & Body","description":"woman bowing","unicode_version":"6.0"},{"emoji":"🤦","aliases":["facepalm"],"tags":[],"category":"People & Body","description":"person facepalming","unicode_version":"11.0"},{"emoji":"🤦‍♂️","aliases":["man_facepalming"],"tags":[],"category":"People & Body","description":"man facepalming","unicode_version":"9.0"},{"emoji":"🤦‍♀️","aliases":["woman_facepalming"],"tags":[],"category":"People & Body","description":"woman facepalming","unicode_version":"9.0"},{"emoji":"🤷","aliases":["shrug"],"tags":[],"category":"People & Body","description":"person shrugging","unicode_version":"11.0"},{"emoji":"🤷‍♂️","aliases":["man_shrugging"],"tags":[],"category":"People & Body","description":"man shrugging","unicode_version":"9.0"},{"emoji":"🤷‍♀️","aliases":["woman_shrugging"],"tags":[],"category":"People & Body","description":"woman shrugging","unicode_version":"9.0"},{"emoji":"🧑‍⚕️","aliases":["health_worker"],"tags":[],"category":"People & Body","description":"health worker","unicode_version":"12.1"},{"emoji":"👨‍⚕️","aliases":["man_health_worker"],"tags":["doctor","nurse"],"category":"People & Body","description":"man health worker","unicode_version":""},{"emoji":"👩‍⚕️","aliases":["woman_health_worker"],"tags":["doctor","nurse"],"category":"People & Body","description":"woman health worker","unicode_version":""},{"emoji":"🧑‍🎓","aliases":["student"],"tags":[],"category":"People & Body","description":"student","unicode_version":"12.1"},{"emoji":"👨‍🎓","aliases":["man_student"],"tags":["graduation"],"category":"People & Body","description":"man student","unicode_version":""},{"emoji":"👩‍🎓","aliases":["woman_student"],"tags":["graduation"],"category":"People & Body","description":"woman student","unicode_version":""},{"emoji":"🧑‍🏫","aliases":["teacher"],"tags":[],"category":"People & Body","description":"teacher","unicode_version":"12.1"},{"emoji":"👨‍🏫","aliases":["man_teacher"],"tags":["school","professor"],"category":"People & Body","description":"man teacher","unicode_version":""},{"emoji":"👩‍🏫","aliases":["woman_teacher"],"tags":["school","professor"],"category":"People & Body","description":"woman teacher","unicode_version":""},{"emoji":"🧑‍⚖️","aliases":["judge"],"tags":[],"category":"People & Body","description":"judge","unicode_version":"12.1"},{"emoji":"👨‍⚖️","aliases":["man_judge"],"tags":["justice"],"category":"People & Body","description":"man judge","unicode_version":""},{"emoji":"👩‍⚖️","aliases":["woman_judge"],"tags":["justice"],"category":"People & Body","description":"woman judge","unicode_version":""},{"emoji":"🧑‍🌾","aliases":["farmer"],"tags":[],"category":"People & Body","description":"farmer","unicode_version":"12.1"},{"emoji":"👨‍🌾","aliases":["man_farmer"],"tags":[],"category":"People & Body","description":"man farmer","unicode_version":""},{"emoji":"👩‍🌾","aliases":["woman_farmer"],"tags":[],"category":"People & Body","description":"woman farmer","unicode_version":""},{"emoji":"🧑‍🍳","aliases":["cook"],"tags":[],"category":"People & Body","description":"cook","unicode_version":"12.1"},{"emoji":"👨‍🍳","aliases":["man_cook"],"tags":["chef"],"category":"People & Body","description":"man cook","unicode_version":""},{"emoji":"👩‍🍳","aliases":["woman_cook"],"tags":["chef"],"category":"People & Body","description":"woman cook","unicode_version":""},{"emoji":"🧑‍🔧","aliases":["mechanic"],"tags":[],"category":"People & Body","description":"mechanic","unicode_version":"12.1"},{"emoji":"👨‍🔧","aliases":["man_mechanic"],"tags":[],"category":"People & Body","description":"man mechanic","unicode_version":""},{"emoji":"👩‍🔧","aliases":["woman_mechanic"],"tags":[],"category":"People & Body","description":"woman mechanic","unicode_version":""},{"emoji":"🧑‍🏭","aliases":["factory_worker"],"tags":[],"category":"People & Body","description":"factory worker","unicode_version":"12.1"},{"emoji":"👨‍🏭","aliases":["man_factory_worker"],"tags":[],"category":"People & Body","description":"man factory worker","unicode_version":""},{"emoji":"👩‍🏭","aliases":["woman_factory_worker"],"tags":[],"category":"People & Body","description":"woman factory worker","unicode_version":""},{"emoji":"🧑‍💼","aliases":["office_worker"],"tags":[],"category":"People & Body","description":"office worker","unicode_version":"12.1"},{"emoji":"👨‍💼","aliases":["man_office_worker"],"tags":["business"],"category":"People & Body","description":"man office worker","unicode_version":""},{"emoji":"👩‍💼","aliases":["woman_office_worker"],"tags":["business"],"category":"People & Body","description":"woman office worker","unicode_version":""},{"emoji":"🧑‍🔬","aliases":["scientist"],"tags":[],"category":"People & Body","description":"scientist","unicode_version":"12.1"},{"emoji":"👨‍🔬","aliases":["man_scientist"],"tags":["research"],"category":"People & Body","description":"man scientist","unicode_version":""},{"emoji":"👩‍🔬","aliases":["woman_scientist"],"tags":["research"],"category":"People & Body","description":"woman scientist","unicode_version":""},{"emoji":"🧑‍💻","aliases":["technologist"],"tags":[],"category":"People & Body","description":"technologist","unicode_version":"12.1"},{"emoji":"👨‍💻","aliases":["man_technologist"],"tags":["coder"],"category":"People & Body","description":"man technologist","unicode_version":""},{"emoji":"👩‍💻","aliases":["woman_technologist"],"tags":["coder"],"category":"People & Body","description":"woman technologist","unicode_version":""},{"emoji":"🧑‍🎤","aliases":["singer"],"tags":[],"category":"People & Body","description":"singer","unicode_version":"12.1"},{"emoji":"👨‍🎤","aliases":["man_singer"],"tags":["rockstar"],"category":"People & Body","description":"man singer","unicode_version":""},{"emoji":"👩‍🎤","aliases":["woman_singer"],"tags":["rockstar"],"category":"People & Body","description":"woman singer","unicode_version":""},{"emoji":"🧑‍🎨","aliases":["artist"],"tags":[],"category":"People & Body","description":"artist","unicode_version":"12.1"},{"emoji":"👨‍🎨","aliases":["man_artist"],"tags":["painter"],"category":"People & Body","description":"man artist","unicode_version":""},{"emoji":"👩‍🎨","aliases":["woman_artist"],"tags":["painter"],"category":"People & Body","description":"woman artist","unicode_version":""},{"emoji":"🧑‍✈️","aliases":["pilot"],"tags":[],"category":"People & Body","description":"pilot","unicode_version":"12.1"},{"emoji":"👨‍✈️","aliases":["man_pilot"],"tags":[],"category":"People & Body","description":"man pilot","unicode_version":""},{"emoji":"👩‍✈️","aliases":["woman_pilot"],"tags":[],"category":"People & Body","description":"woman pilot","unicode_version":""},{"emoji":"🧑‍🚀","aliases":["astronaut"],"tags":[],"category":"People & Body","description":"astronaut","unicode_version":"12.1"},{"emoji":"👨‍🚀","aliases":["man_astronaut"],"tags":["space"],"category":"People & Body","description":"man astronaut","unicode_version":""},{"emoji":"👩‍🚀","aliases":["woman_astronaut"],"tags":["space"],"category":"People & Body","description":"woman astronaut","unicode_version":""},{"emoji":"🧑‍🚒","aliases":["firefighter"],"tags":[],"category":"People & Body","description":"firefighter","unicode_version":"12.1"},{"emoji":"👨‍🚒","aliases":["man_firefighter"],"tags":[],"category":"People & Body","description":"man firefighter","unicode_version":""},{"emoji":"👩‍🚒","aliases":["woman_firefighter"],"tags":[],"category":"People & Body","description":"woman firefighter","unicode_version":""},{"emoji":"👮","aliases":["police_officer","cop"],"tags":["law"],"category":"People & Body","description":"police officer","unicode_version":"6.0"},{"emoji":"👮‍♂️","aliases":["policeman"],"tags":["law","cop"],"category":"People & Body","description":"man police officer","unicode_version":"11.0"},{"emoji":"👮‍♀️","aliases":["policewoman"],"tags":["law","cop"],"category":"People & Body","description":"woman police officer","unicode_version":"6.0"},{"emoji":"🕵️","aliases":["detective"],"tags":["sleuth"],"category":"People & Body","description":"detective","unicode_version":"7.0"},{"emoji":"🕵️‍♂️","aliases":["male_detective"],"tags":["sleuth"],"category":"People & Body","description":"man detective","unicode_version":"11.0"},{"emoji":"🕵️‍♀️","aliases":["female_detective"],"tags":["sleuth"],"category":"People & Body","description":"woman detective","unicode_version":"6.0"},{"emoji":"💂","aliases":["guard"],"tags":[],"category":"People & Body","description":"guard","unicode_version":"6.0"},{"emoji":"💂‍♂️","aliases":["guardsman"],"tags":[],"category":"People & Body","description":"man guard","unicode_version":"11.0"},{"emoji":"💂‍♀️","aliases":["guardswoman"],"tags":[],"category":"People & Body","description":"woman guard","unicode_version":"6.0"},{"emoji":"🥷","aliases":["ninja"],"tags":[],"category":"People & Body","description":"ninja","unicode_version":"13.0"},{"emoji":"👷","aliases":["construction_worker"],"tags":["helmet"],"category":"People & Body","description":"construction worker","unicode_version":"6.0"},{"emoji":"👷‍♂️","aliases":["construction_worker_man"],"tags":["helmet"],"category":"People & Body","description":"man construction worker","unicode_version":"11.0"},{"emoji":"👷‍♀️","aliases":["construction_worker_woman"],"tags":["helmet"],"category":"People & Body","description":"woman construction worker","unicode_version":"6.0"},{"emoji":"🤴","aliases":["prince"],"tags":["crown","royal"],"category":"People & Body","description":"prince","unicode_version":"9.0"},{"emoji":"👸","aliases":["princess"],"tags":["crown","royal"],"category":"People & Body","description":"princess","unicode_version":"6.0"},{"emoji":"👳","aliases":["person_with_turban"],"tags":[],"category":"People & Body","description":"person wearing turban","unicode_version":"6.0"},{"emoji":"👳‍♂️","aliases":["man_with_turban"],"tags":[],"category":"People & Body","description":"man wearing turban","unicode_version":"11.0"},{"emoji":"👳‍♀️","aliases":["woman_with_turban"],"tags":[],"category":"People & Body","description":"woman wearing turban","unicode_version":"6.0"},{"emoji":"👲","aliases":["man_with_gua_pi_mao"],"tags":[],"category":"People & Body","description":"person with skullcap","unicode_version":"6.0"},{"emoji":"🧕","aliases":["woman_with_headscarf"],"tags":["hijab"],"category":"People & Body","description":"woman with headscarf","unicode_version":"11.0"},{"emoji":"🤵","aliases":["person_in_tuxedo"],"tags":["groom","marriage","wedding"],"category":"People & Body","description":"person in tuxedo","unicode_version":"9.0"},{"emoji":"🤵‍♂️","aliases":["man_in_tuxedo"],"tags":[],"category":"People & Body","description":"man in tuxedo","unicode_version":"13.0"},{"emoji":"🤵‍♀️","aliases":["woman_in_tuxedo"],"tags":[],"category":"People & Body","description":"woman in tuxedo","unicode_version":"13.0"},{"emoji":"👰","aliases":["person_with_veil"],"tags":["marriage","wedding"],"category":"People & Body","description":"person with veil","unicode_version":"6.0"},{"emoji":"👰‍♂️","aliases":["man_with_veil"],"tags":[],"category":"People & Body","description":"man with veil","unicode_version":"13.0"},{"emoji":"👰‍♀️","aliases":["woman_with_veil","bride_with_veil"],"tags":[],"category":"People & Body","description":"woman with veil","unicode_version":"13.0"},{"emoji":"🤰","aliases":["pregnant_woman"],"tags":[],"category":"People & Body","description":"pregnant woman","unicode_version":"9.0"},{"emoji":"🤱","aliases":["breast_feeding"],"tags":["nursing"],"category":"People & Body","description":"breast-feeding","unicode_version":"11.0"},{"emoji":"👩‍🍼","aliases":["woman_feeding_baby"],"tags":[],"category":"People & Body","description":"woman feeding baby","unicode_version":"13.0"},{"emoji":"👨‍🍼","aliases":["man_feeding_baby"],"tags":[],"category":"People & Body","description":"man feeding baby","unicode_version":"13.0"},{"emoji":"🧑‍🍼","aliases":["person_feeding_baby"],"tags":[],"category":"People & Body","description":"person feeding baby","unicode_version":"13.0"},{"emoji":"👼","aliases":["angel"],"tags":[],"category":"People & Body","description":"baby angel","unicode_version":"6.0"},{"emoji":"🎅","aliases":["santa"],"tags":["christmas"],"category":"People & Body","description":"Santa Claus","unicode_version":"6.0"},{"emoji":"🤶","aliases":["mrs_claus"],"tags":["santa"],"category":"People & Body","description":"Mrs. Claus","unicode_version":"9.0"},{"emoji":"🧑‍🎄","aliases":["mx_claus"],"tags":[],"category":"People & Body","description":"mx claus","unicode_version":"13.0"},{"emoji":"🦸","aliases":["superhero"],"tags":[],"category":"People & Body","description":"superhero","unicode_version":"11.0"},{"emoji":"🦸‍♂️","aliases":["superhero_man"],"tags":[],"category":"People & Body","description":"man superhero","unicode_version":"11.0"},{"emoji":"🦸‍♀️","aliases":["superhero_woman"],"tags":[],"category":"People & Body","description":"woman superhero","unicode_version":"11.0"},{"emoji":"🦹","aliases":["supervillain"],"tags":[],"category":"People & Body","description":"supervillain","unicode_version":"11.0"},{"emoji":"🦹‍♂️","aliases":["supervillain_man"],"tags":[],"category":"People & Body","description":"man supervillain","unicode_version":"11.0"},{"emoji":"🦹‍♀️","aliases":["supervillain_woman"],"tags":[],"category":"People & Body","description":"woman supervillain","unicode_version":"11.0"},{"emoji":"🧙","aliases":["mage"],"tags":["wizard"],"category":"People & Body","description":"mage","unicode_version":"11.0"},{"emoji":"🧙‍♂️","aliases":["mage_man"],"tags":["wizard"],"category":"People & Body","description":"man mage","unicode_version":"11.0"},{"emoji":"🧙‍♀️","aliases":["mage_woman"],"tags":["wizard"],"category":"People & Body","description":"woman mage","unicode_version":"11.0"},{"emoji":"🧚","aliases":["fairy"],"tags":[],"category":"People & Body","description":"fairy","unicode_version":"11.0"},{"emoji":"🧚‍♂️","aliases":["fairy_man"],"tags":[],"category":"People & Body","description":"man fairy","unicode_version":"11.0"},{"emoji":"🧚‍♀️","aliases":["fairy_woman"],"tags":[],"category":"People & Body","description":"woman fairy","unicode_version":"11.0"},{"emoji":"🧛","aliases":["vampire"],"tags":[],"category":"People & Body","description":"vampire","unicode_version":"11.0"},{"emoji":"🧛‍♂️","aliases":["vampire_man"],"tags":[],"category":"People & Body","description":"man vampire","unicode_version":"11.0"},{"emoji":"🧛‍♀️","aliases":["vampire_woman"],"tags":[],"category":"People & Body","description":"woman vampire","unicode_version":"11.0"},{"emoji":"🧜","aliases":["merperson"],"tags":[],"category":"People & Body","description":"merperson","unicode_version":"11.0"},{"emoji":"🧜‍♂️","aliases":["merman"],"tags":[],"category":"People & Body","description":"merman","unicode_version":"11.0"},{"emoji":"🧜‍♀️","aliases":["mermaid"],"tags":[],"category":"People & Body","description":"mermaid","unicode_version":"11.0"},{"emoji":"🧝","aliases":["elf"],"tags":[],"category":"People & Body","description":"elf","unicode_version":"11.0"},{"emoji":"🧝‍♂️","aliases":["elf_man"],"tags":[],"category":"People & Body","description":"man elf","unicode_version":"11.0"},{"emoji":"🧝‍♀️","aliases":["elf_woman"],"tags":[],"category":"People & Body","description":"woman elf","unicode_version":"11.0"},{"emoji":"🧞","aliases":["genie"],"tags":[],"category":"People & Body","description":"genie","unicode_version":"11.0"},{"emoji":"🧞‍♂️","aliases":["genie_man"],"tags":[],"category":"People & Body","description":"man genie","unicode_version":"11.0"},{"emoji":"🧞‍♀️","aliases":["genie_woman"],"tags":[],"category":"People & Body","description":"woman genie","unicode_version":"11.0"},{"emoji":"🧟","aliases":["zombie"],"tags":[],"category":"People & Body","description":"zombie","unicode_version":"11.0"},{"emoji":"🧟‍♂️","aliases":["zombie_man"],"tags":[],"category":"People & Body","description":"man zombie","unicode_version":"11.0"},{"emoji":"🧟‍♀️","aliases":["zombie_woman"],"tags":[],"category":"People & Body","description":"woman zombie","unicode_version":"11.0"},{"emoji":"💆","aliases":["massage"],"tags":["spa"],"category":"People & Body","description":"person getting massage","unicode_version":"6.0"},{"emoji":"💆‍♂️","aliases":["massage_man"],"tags":["spa"],"category":"People & Body","description":"man getting massage","unicode_version":"6.0"},{"emoji":"💆‍♀️","aliases":["massage_woman"],"tags":["spa"],"category":"People & Body","description":"woman getting massage","unicode_version":"11.0"},{"emoji":"💇","aliases":["haircut"],"tags":["beauty"],"category":"People & Body","description":"person getting haircut","unicode_version":"6.0"},{"emoji":"💇‍♂️","aliases":["haircut_man"],"tags":[],"category":"People & Body","description":"man getting haircut","unicode_version":"6.0"},{"emoji":"💇‍♀️","aliases":["haircut_woman"],"tags":[],"category":"People & Body","description":"woman getting haircut","unicode_version":"11.0"},{"emoji":"🚶","aliases":["walking"],"tags":[],"category":"People & Body","description":"person walking","unicode_version":"6.0"},{"emoji":"🚶‍♂️","aliases":["walking_man"],"tags":[],"category":"People & Body","description":"man walking","unicode_version":"11.0"},{"emoji":"🚶‍♀️","aliases":["walking_woman"],"tags":[],"category":"People & Body","description":"woman walking","unicode_version":"6.0"},{"emoji":"🧍","aliases":["standing_person"],"tags":[],"category":"People & Body","description":"person standing","unicode_version":"12.0"},{"emoji":"🧍‍♂️","aliases":["standing_man"],"tags":[],"category":"People & Body","description":"man standing","unicode_version":"12.0"},{"emoji":"🧍‍♀️","aliases":["standing_woman"],"tags":[],"category":"People & Body","description":"woman standing","unicode_version":"12.0"},{"emoji":"🧎","aliases":["kneeling_person"],"tags":[],"category":"People & Body","description":"person kneeling","unicode_version":"12.0"},{"emoji":"🧎‍♂️","aliases":["kneeling_man"],"tags":[],"category":"People & Body","description":"man kneeling","unicode_version":"12.0"},{"emoji":"🧎‍♀️","aliases":["kneeling_woman"],"tags":[],"category":"People & Body","description":"woman kneeling","unicode_version":"12.0"},{"emoji":"🧑‍🦯","aliases":["person_with_probing_cane"],"tags":[],"category":"People & Body","description":"person with white cane","unicode_version":"12.1"},{"emoji":"👨‍🦯","aliases":["man_with_probing_cane"],"tags":[],"category":"People & Body","description":"man with white cane","unicode_version":"12.0"},{"emoji":"👩‍🦯","aliases":["woman_with_probing_cane"],"tags":[],"category":"People & Body","description":"woman with white cane","unicode_version":"12.0"},{"emoji":"🧑‍🦼","aliases":["person_in_motorized_wheelchair"],"tags":[],"category":"People & Body","description":"person in motorized wheelchair","unicode_version":"12.1"},{"emoji":"👨‍🦼","aliases":["man_in_motorized_wheelchair"],"tags":[],"category":"People & Body","description":"man in motorized wheelchair","unicode_version":"12.0"},{"emoji":"👩‍🦼","aliases":["woman_in_motorized_wheelchair"],"tags":[],"category":"People & Body","description":"woman in motorized wheelchair","unicode_version":"12.0"},{"emoji":"🧑‍🦽","aliases":["person_in_manual_wheelchair"],"tags":[],"category":"People & Body","description":"person in manual wheelchair","unicode_version":"12.1"},{"emoji":"👨‍🦽","aliases":["man_in_manual_wheelchair"],"tags":[],"category":"People & Body","description":"man in manual wheelchair","unicode_version":"12.0"},{"emoji":"👩‍🦽","aliases":["woman_in_manual_wheelchair"],"tags":[],"category":"People & Body","description":"woman in manual wheelchair","unicode_version":"12.0"},{"emoji":"🏃","aliases":["runner","running"],"tags":["exercise","workout","marathon"],"category":"People & Body","description":"person running","unicode_version":"6.0"},{"emoji":"🏃‍♂️","aliases":["running_man"],"tags":["exercise","workout","marathon"],"category":"People & Body","description":"man running","unicode_version":"11.0"},{"emoji":"🏃‍♀️","aliases":["running_woman"],"tags":["exercise","workout","marathon"],"category":"People & Body","description":"woman running","unicode_version":"6.0"},{"emoji":"💃","aliases":["woman_dancing","dancer"],"tags":["dress"],"category":"People & Body","description":"woman dancing","unicode_version":"6.0"},{"emoji":"🕺","aliases":["man_dancing"],"tags":["dancer"],"category":"People & Body","description":"man dancing","unicode_version":"9.0"},{"emoji":"🕴️","aliases":["business_suit_levitating"],"tags":[],"category":"People & Body","description":"person in suit levitating","unicode_version":"7.0"},{"emoji":"👯","aliases":["dancers"],"tags":["bunny"],"category":"People & Body","description":"people with bunny ears","unicode_version":"6.0"},{"emoji":"👯‍♂️","aliases":["dancing_men"],"tags":["bunny"],"category":"People & Body","description":"men with bunny ears","unicode_version":"6.0"},{"emoji":"👯‍♀️","aliases":["dancing_women"],"tags":["bunny"],"category":"People & Body","description":"women with bunny ears","unicode_version":"11.0"},{"emoji":"🧖","aliases":["sauna_person"],"tags":["steamy"],"category":"People & Body","description":"person in steamy room","unicode_version":"11.0"},{"emoji":"🧖‍♂️","aliases":["sauna_man"],"tags":["steamy"],"category":"People & Body","description":"man in steamy room","unicode_version":"11.0"},{"emoji":"🧖‍♀️","aliases":["sauna_woman"],"tags":["steamy"],"category":"People & Body","description":"woman in steamy room","unicode_version":"11.0"},{"emoji":"🧗","aliases":["climbing"],"tags":["bouldering"],"category":"People & Body","description":"person climbing","unicode_version":"11.0"},{"emoji":"🧗‍♂️","aliases":["climbing_man"],"tags":["bouldering"],"category":"People & Body","description":"man climbing","unicode_version":"11.0"},{"emoji":"🧗‍♀️","aliases":["climbing_woman"],"tags":["bouldering"],"category":"People & Body","description":"woman climbing","unicode_version":"11.0"},{"emoji":"🤺","aliases":["person_fencing"],"tags":[],"category":"People & Body","description":"person fencing","unicode_version":"9.0"},{"emoji":"🏇","aliases":["horse_racing"],"tags":[],"category":"People & Body","description":"horse racing","unicode_version":"6.0"},{"emoji":"⛷️","aliases":["skier"],"tags":[],"category":"People & Body","description":"skier","unicode_version":"5.2"},{"emoji":"🏂","aliases":["snowboarder"],"tags":[],"category":"People & Body","description":"snowboarder","unicode_version":"6.0"},{"emoji":"🏌️","aliases":["golfing"],"tags":[],"category":"People & Body","description":"person golfing","unicode_version":"7.0"},{"emoji":"🏌️‍♂️","aliases":["golfing_man"],"tags":[],"category":"People & Body","description":"man golfing","unicode_version":"11.0"},{"emoji":"🏌️‍♀️","aliases":["golfing_woman"],"tags":[],"category":"People & Body","description":"woman golfing","unicode_version":""},{"emoji":"🏄","aliases":["surfer"],"tags":[],"category":"People & Body","description":"person surfing","unicode_version":"6.0"},{"emoji":"🏄‍♂️","aliases":["surfing_man"],"tags":[],"category":"People & Body","description":"man surfing","unicode_version":"11.0"},{"emoji":"🏄‍♀️","aliases":["surfing_woman"],"tags":[],"category":"People & Body","description":"woman surfing","unicode_version":"7.0"},{"emoji":"🚣","aliases":["rowboat"],"tags":[],"category":"People & Body","description":"person rowing boat","unicode_version":"6.0"},{"emoji":"🚣‍♂️","aliases":["rowing_man"],"tags":[],"category":"People & Body","description":"man rowing boat","unicode_version":"11.0"},{"emoji":"🚣‍♀️","aliases":["rowing_woman"],"tags":[],"category":"People & Body","description":"woman rowing boat","unicode_version":"6.0"},{"emoji":"🏊","aliases":["swimmer"],"tags":[],"category":"People & Body","description":"person swimming","unicode_version":"6.0"},{"emoji":"🏊‍♂️","aliases":["swimming_man"],"tags":[],"category":"People & Body","description":"man swimming","unicode_version":"11.0"},{"emoji":"🏊‍♀️","aliases":["swimming_woman"],"tags":[],"category":"People & Body","description":"woman swimming","unicode_version":"6.0"},{"emoji":"⛹️","aliases":["bouncing_ball_person"],"tags":["basketball"],"category":"People & Body","description":"person bouncing ball","unicode_version":"5.2"},{"emoji":"⛹️‍♂️","aliases":["bouncing_ball_man","basketball_man"],"tags":[],"category":"People & Body","description":"man bouncing ball","unicode_version":"11.0"},{"emoji":"⛹️‍♀️","aliases":["bouncing_ball_woman","basketball_woman"],"tags":[],"category":"People & Body","description":"woman bouncing ball","unicode_version":"7.0"},{"emoji":"🏋️","aliases":["weight_lifting"],"tags":["gym","workout"],"category":"People & Body","description":"person lifting weights","unicode_version":"7.0"},{"emoji":"🏋️‍♂️","aliases":["weight_lifting_man"],"tags":["gym","workout"],"category":"People & Body","description":"man lifting weights","unicode_version":"11.0"},{"emoji":"🏋️‍♀️","aliases":["weight_lifting_woman"],"tags":["gym","workout"],"category":"People & Body","description":"woman lifting weights","unicode_version":"6.0"},{"emoji":"🚴","aliases":["bicyclist"],"tags":[],"category":"People & Body","description":"person biking","unicode_version":"6.0"},{"emoji":"🚴‍♂️","aliases":["biking_man"],"tags":[],"category":"People & Body","description":"man biking","unicode_version":"11.0"},{"emoji":"🚴‍♀️","aliases":["biking_woman"],"tags":[],"category":"People & Body","description":"woman biking","unicode_version":"6.0"},{"emoji":"🚵","aliases":["mountain_bicyclist"],"tags":[],"category":"People & Body","description":"person mountain biking","unicode_version":"6.0"},{"emoji":"🚵‍♂️","aliases":["mountain_biking_man"],"tags":[],"category":"People & Body","description":"man mountain biking","unicode_version":"11.0"},{"emoji":"🚵‍♀️","aliases":["mountain_biking_woman"],"tags":[],"category":"People & Body","description":"woman mountain biking","unicode_version":"6.0"},{"emoji":"🤸","aliases":["cartwheeling"],"tags":[],"category":"People & Body","description":"person cartwheeling","unicode_version":"11.0"},{"emoji":"🤸‍♂️","aliases":["man_cartwheeling"],"tags":[],"category":"People & Body","description":"man cartwheeling","unicode_version":""},{"emoji":"🤸‍♀️","aliases":["woman_cartwheeling"],"tags":[],"category":"People & Body","description":"woman cartwheeling","unicode_version":""},{"emoji":"🤼","aliases":["wrestling"],"tags":[],"category":"People & Body","description":"people wrestling","unicode_version":"11.0"},{"emoji":"🤼‍♂️","aliases":["men_wrestling"],"tags":[],"category":"People & Body","description":"men wrestling","unicode_version":"9.0"},{"emoji":"🤼‍♀️","aliases":["women_wrestling"],"tags":[],"category":"People & Body","description":"women wrestling","unicode_version":"9.0"},{"emoji":"🤽","aliases":["water_polo"],"tags":[],"category":"People & Body","description":"person playing water polo","unicode_version":"11.0"},{"emoji":"🤽‍♂️","aliases":["man_playing_water_polo"],"tags":[],"category":"People & Body","description":"man playing water polo","unicode_version":"9.0"},{"emoji":"🤽‍♀️","aliases":["woman_playing_water_polo"],"tags":[],"category":"People & Body","description":"woman playing water polo","unicode_version":"9.0"},{"emoji":"🤾","aliases":["handball_person"],"tags":[],"category":"People & Body","description":"person playing handball","unicode_version":"11.0"},{"emoji":"🤾‍♂️","aliases":["man_playing_handball"],"tags":[],"category":"People & Body","description":"man playing handball","unicode_version":"9.0"},{"emoji":"🤾‍♀️","aliases":["woman_playing_handball"],"tags":[],"category":"People & Body","description":"woman playing handball","unicode_version":"9.0"},{"emoji":"🤹","aliases":["juggling_person"],"tags":[],"category":"People & Body","description":"person juggling","unicode_version":"11.0"},{"emoji":"🤹‍♂️","aliases":["man_juggling"],"tags":[],"category":"People & Body","description":"man juggling","unicode_version":"9.0"},{"emoji":"🤹‍♀️","aliases":["woman_juggling"],"tags":[],"category":"People & Body","description":"woman juggling","unicode_version":"9.0"},{"emoji":"🧘","aliases":["lotus_position"],"tags":["meditation"],"category":"People & Body","description":"person in lotus position","unicode_version":"11.0"},{"emoji":"🧘‍♂️","aliases":["lotus_position_man"],"tags":["meditation"],"category":"People & Body","description":"man in lotus position","unicode_version":"11.0"},{"emoji":"🧘‍♀️","aliases":["lotus_position_woman"],"tags":["meditation"],"category":"People & Body","description":"woman in lotus position","unicode_version":"11.0"},{"emoji":"🛀","aliases":["bath"],"tags":["shower"],"category":"People & Body","description":"person taking bath","unicode_version":"6.0"},{"emoji":"🛌","aliases":["sleeping_bed"],"tags":[],"category":"People & Body","description":"person in bed","unicode_version":"7.0"},{"emoji":"🧑‍🤝‍🧑","aliases":["people_holding_hands"],"tags":["couple","date"],"category":"People & Body","description":"people holding hands","unicode_version":"12.0"},{"emoji":"👭","aliases":["two_women_holding_hands"],"tags":["couple","date"],"category":"People & Body","description":"women holding hands","unicode_version":"6.0"},{"emoji":"👫","aliases":["couple"],"tags":["date"],"category":"People & Body","description":"woman and man holding hands","unicode_version":"6.0"},{"emoji":"👬","aliases":["two_men_holding_hands"],"tags":["couple","date"],"category":"People & Body","description":"men holding hands","unicode_version":"6.0"},{"emoji":"💏","aliases":["couplekiss"],"tags":[],"category":"People & Body","description":"kiss","unicode_version":"6.0"},{"emoji":"👩‍❤️‍💋‍👨","aliases":["couplekiss_man_woman"],"tags":[],"category":"People & Body","description":"kiss: woman, man","unicode_version":"11.0"},{"emoji":"👨‍❤️‍💋‍👨","aliases":["couplekiss_man_man"],"tags":[],"category":"People & Body","description":"kiss: man, man","unicode_version":"6.0"},{"emoji":"👩‍❤️‍💋‍👩","aliases":["couplekiss_woman_woman"],"tags":[],"category":"People & Body","description":"kiss: woman, woman","unicode_version":"6.0"},{"emoji":"💑","aliases":["couple_with_heart"],"tags":[],"category":"People & Body","description":"couple with heart","unicode_version":"6.0"},{"emoji":"👩‍❤️‍👨","aliases":["couple_with_heart_woman_man"],"tags":[],"category":"People & Body","description":"couple with heart: woman, man","unicode_version":"11.0"},{"emoji":"👨‍❤️‍👨","aliases":["couple_with_heart_man_man"],"tags":[],"category":"People & Body","description":"couple with heart: man, man","unicode_version":"6.0"},{"emoji":"👩‍❤️‍👩","aliases":["couple_with_heart_woman_woman"],"tags":[],"category":"People & Body","description":"couple with heart: woman, woman","unicode_version":"6.0"},{"emoji":"👪","aliases":["family"],"tags":["home","parents","child"],"category":"People & Body","description":"family","unicode_version":"6.0"},{"emoji":"👨‍👩‍👦","aliases":["family_man_woman_boy"],"tags":[],"category":"People & Body","description":"family: man, woman, boy","unicode_version":"11.0"},{"emoji":"👨‍👩‍👧","aliases":["family_man_woman_girl"],"tags":[],"category":"People & Body","description":"family: man, woman, girl","unicode_version":"6.0"},{"emoji":"👨‍👩‍👧‍👦","aliases":["family_man_woman_girl_boy"],"tags":[],"category":"People & Body","description":"family: man, woman, girl, boy","unicode_version":"6.0"},{"emoji":"👨‍👩‍👦‍👦","aliases":["family_man_woman_boy_boy"],"tags":[],"category":"People & Body","description":"family: man, woman, boy, boy","unicode_version":"6.0"},{"emoji":"👨‍👩‍👧‍👧","aliases":["family_man_woman_girl_girl"],"tags":[],"category":"People & Body","description":"family: man, woman, girl, girl","unicode_version":"6.0"},{"emoji":"👨‍👨‍👦","aliases":["family_man_man_boy"],"tags":[],"category":"People & Body","description":"family: man, man, boy","unicode_version":"6.0"},{"emoji":"👨‍👨‍👧","aliases":["family_man_man_girl"],"tags":[],"category":"People & Body","description":"family: man, man, girl","unicode_version":"6.0"},{"emoji":"👨‍👨‍👧‍👦","aliases":["family_man_man_girl_boy"],"tags":[],"category":"People & Body","description":"family: man, man, girl, boy","unicode_version":"6.0"},{"emoji":"👨‍👨‍👦‍👦","aliases":["family_man_man_boy_boy"],"tags":[],"category":"People & Body","description":"family: man, man, boy, boy","unicode_version":"6.0"},{"emoji":"👨‍👨‍👧‍👧","aliases":["family_man_man_girl_girl"],"tags":[],"category":"People & Body","description":"family: man, man, girl, girl","unicode_version":"6.0"},{"emoji":"👩‍👩‍👦","aliases":["family_woman_woman_boy"],"tags":[],"category":"People & Body","description":"family: woman, woman, boy","unicode_version":"6.0"},{"emoji":"👩‍👩‍👧","aliases":["family_woman_woman_girl"],"tags":[],"category":"People & Body","description":"family: woman, woman, girl","unicode_version":"6.0"},{"emoji":"👩‍👩‍👧‍👦","aliases":["family_woman_woman_girl_boy"],"tags":[],"category":"People & Body","description":"family: woman, woman, girl, boy","unicode_version":"6.0"},{"emoji":"👩‍👩‍👦‍👦","aliases":["family_woman_woman_boy_boy"],"tags":[],"category":"People & Body","description":"family: woman, woman, boy, boy","unicode_version":"6.0"},{"emoji":"👩‍👩‍👧‍👧","aliases":["family_woman_woman_girl_girl"],"tags":[],"category":"People & Body","description":"family: woman, woman, girl, girl","unicode_version":"6.0"},{"emoji":"👨‍👦","aliases":["family_man_boy"],"tags":[],"category":"People & Body","description":"family: man, boy","unicode_version":"6.0"},{"emoji":"👨‍👦‍👦","aliases":["family_man_boy_boy"],"tags":[],"category":"People & Body","description":"family: man, boy, boy","unicode_version":"6.0"},{"emoji":"👨‍👧","aliases":["family_man_girl"],"tags":[],"category":"People & Body","description":"family: man, girl","unicode_version":"6.0"},{"emoji":"👨‍👧‍👦","aliases":["family_man_girl_boy"],"tags":[],"category":"People & Body","description":"family: man, girl, boy","unicode_version":"6.0"},{"emoji":"👨‍👧‍👧","aliases":["family_man_girl_girl"],"tags":[],"category":"People & Body","description":"family: man, girl, girl","unicode_version":"6.0"},{"emoji":"👩‍👦","aliases":["family_woman_boy"],"tags":[],"category":"People & Body","description":"family: woman, boy","unicode_version":"6.0"},{"emoji":"👩‍👦‍👦","aliases":["family_woman_boy_boy"],"tags":[],"category":"People & Body","description":"family: woman, boy, boy","unicode_version":"6.0"},{"emoji":"👩‍👧","aliases":["family_woman_girl"],"tags":[],"category":"People & Body","description":"family: woman, girl","unicode_version":"6.0"},{"emoji":"👩‍👧‍👦","aliases":["family_woman_girl_boy"],"tags":[],"category":"People & Body","description":"family: woman, girl, boy","unicode_version":"6.0"},{"emoji":"👩‍👧‍👧","aliases":["family_woman_girl_girl"],"tags":[],"category":"People & Body","description":"family: woman, girl, girl","unicode_version":"6.0"},{"emoji":"🗣️","aliases":["speaking_head"],"tags":[],"category":"People & Body","description":"speaking head","unicode_version":"7.0"},{"emoji":"👤","aliases":["bust_in_silhouette"],"tags":["user"],"category":"People & Body","description":"bust in silhouette","unicode_version":"6.0"},{"emoji":"👥","aliases":["busts_in_silhouette"],"tags":["users","group","team"],"category":"People & Body","description":"busts in silhouette","unicode_version":"6.0"},{"emoji":"🫂","aliases":["people_hugging"],"tags":[],"category":"People & Body","description":"people hugging","unicode_version":"13.0"},{"emoji":"👣","aliases":["footprints"],"tags":["feet","tracks"],"category":"People & Body","description":"footprints","unicode_version":"6.0"},{"emoji":"🐵","aliases":["monkey_face"],"tags":[],"category":"Animals & Nature","description":"monkey face","unicode_version":"6.0"},{"emoji":"🐒","aliases":["monkey"],"tags":[],"category":"Animals & Nature","description":"monkey","unicode_version":"6.0"},{"emoji":"🦍","aliases":["gorilla"],"tags":[],"category":"Animals & Nature","description":"gorilla","unicode_version":"9.0"},{"emoji":"🦧","aliases":["orangutan"],"tags":[],"category":"Animals & Nature","description":"orangutan","unicode_version":"12.0"},{"emoji":"🐶","aliases":["dog"],"tags":["pet"],"category":"Animals & Nature","description":"dog face","unicode_version":"6.0"},{"emoji":"🐕","aliases":["dog2"],"tags":[],"category":"Animals & Nature","description":"dog","unicode_version":"6.0"},{"emoji":"🦮","aliases":["guide_dog"],"tags":[],"category":"Animals & Nature","description":"guide dog","unicode_version":"12.0"},{"emoji":"🐕‍🦺","aliases":["service_dog"],"tags":[],"category":"Animals & Nature","description":"service dog","unicode_version":"12.0"},{"emoji":"🐩","aliases":["poodle"],"tags":["dog"],"category":"Animals & Nature","description":"poodle","unicode_version":"6.0"},{"emoji":"🐺","aliases":["wolf"],"tags":[],"category":"Animals & Nature","description":"wolf","unicode_version":"6.0"},{"emoji":"🦊","aliases":["fox_face"],"tags":[],"category":"Animals & Nature","description":"fox","unicode_version":"9.0"},{"emoji":"🦝","aliases":["raccoon"],"tags":[],"category":"Animals & Nature","description":"raccoon","unicode_version":"11.0"},{"emoji":"🐱","aliases":["cat"],"tags":["pet"],"category":"Animals & Nature","description":"cat face","unicode_version":"6.0"},{"emoji":"🐈","aliases":["cat2"],"tags":[],"category":"Animals & Nature","description":"cat","unicode_version":"6.0"},{"emoji":"🐈‍⬛","aliases":["black_cat"],"tags":[],"category":"Animals & Nature","description":"black cat","unicode_version":"13.0"},{"emoji":"🦁","aliases":["lion"],"tags":[],"category":"Animals & Nature","description":"lion","unicode_version":"8.0"},{"emoji":"🐯","aliases":["tiger"],"tags":[],"category":"Animals & Nature","description":"tiger face","unicode_version":"6.0"},{"emoji":"🐅","aliases":["tiger2"],"tags":[],"category":"Animals & Nature","description":"tiger","unicode_version":"6.0"},{"emoji":"🐆","aliases":["leopard"],"tags":[],"category":"Animals & Nature","description":"leopard","unicode_version":"6.0"},{"emoji":"🐴","aliases":["horse"],"tags":[],"category":"Animals & Nature","description":"horse face","unicode_version":"6.0"},{"emoji":"🐎","aliases":["racehorse"],"tags":["speed"],"category":"Animals & Nature","description":"horse","unicode_version":"6.0"},{"emoji":"🦄","aliases":["unicorn"],"tags":[],"category":"Animals & Nature","description":"unicorn","unicode_version":"8.0"},{"emoji":"🦓","aliases":["zebra"],"tags":[],"category":"Animals & Nature","description":"zebra","unicode_version":"11.0"},{"emoji":"🦌","aliases":["deer"],"tags":[],"category":"Animals & Nature","description":"deer","unicode_version":"9.0"},{"emoji":"🦬","aliases":["bison"],"tags":[],"category":"Animals & Nature","description":"bison","unicode_version":"13.0"},{"emoji":"🐮","aliases":["cow"],"tags":[],"category":"Animals & Nature","description":"cow face","unicode_version":"6.0"},{"emoji":"🐂","aliases":["ox"],"tags":[],"category":"Animals & Nature","description":"ox","unicode_version":"6.0"},{"emoji":"🐃","aliases":["water_buffalo"],"tags":[],"category":"Animals & Nature","description":"water buffalo","unicode_version":"6.0"},{"emoji":"🐄","aliases":["cow2"],"tags":[],"category":"Animals & Nature","description":"cow","unicode_version":"6.0"},{"emoji":"🐷","aliases":["pig"],"tags":[],"category":"Animals & Nature","description":"pig face","unicode_version":"6.0"},{"emoji":"🐖","aliases":["pig2"],"tags":[],"category":"Animals & Nature","description":"pig","unicode_version":"6.0"},{"emoji":"🐗","aliases":["boar"],"tags":[],"category":"Animals & Nature","description":"boar","unicode_version":"6.0"},{"emoji":"🐽","aliases":["pig_nose"],"tags":[],"category":"Animals & Nature","description":"pig nose","unicode_version":"6.0"},{"emoji":"🐏","aliases":["ram"],"tags":[],"category":"Animals & Nature","description":"ram","unicode_version":"6.0"},{"emoji":"🐑","aliases":["sheep"],"tags":[],"category":"Animals & Nature","description":"ewe","unicode_version":"6.0"},{"emoji":"🐐","aliases":["goat"],"tags":[],"category":"Animals & Nature","description":"goat","unicode_version":"6.0"},{"emoji":"🐪","aliases":["dromedary_camel"],"tags":["desert"],"category":"Animals & Nature","description":"camel","unicode_version":"6.0"},{"emoji":"🐫","aliases":["camel"],"tags":[],"category":"Animals & Nature","description":"two-hump camel","unicode_version":"6.0"},{"emoji":"🦙","aliases":["llama"],"tags":[],"category":"Animals & Nature","description":"llama","unicode_version":"11.0"},{"emoji":"🦒","aliases":["giraffe"],"tags":[],"category":"Animals & Nature","description":"giraffe","unicode_version":"11.0"},{"emoji":"🐘","aliases":["elephant"],"tags":[],"category":"Animals & Nature","description":"elephant","unicode_version":"6.0"},{"emoji":"🦣","aliases":["mammoth"],"tags":[],"category":"Animals & Nature","description":"mammoth","unicode_version":"13.0"},{"emoji":"🦏","aliases":["rhinoceros"],"tags":[],"category":"Animals & Nature","description":"rhinoceros","unicode_version":"9.0"},{"emoji":"🦛","aliases":["hippopotamus"],"tags":[],"category":"Animals & Nature","description":"hippopotamus","unicode_version":"11.0"},{"emoji":"🐭","aliases":["mouse"],"tags":[],"category":"Animals & Nature","description":"mouse face","unicode_version":"6.0"},{"emoji":"🐁","aliases":["mouse2"],"tags":[],"category":"Animals & Nature","description":"mouse","unicode_version":"6.0"},{"emoji":"🐀","aliases":["rat"],"tags":[],"category":"Animals & Nature","description":"rat","unicode_version":"6.0"},{"emoji":"🐹","aliases":["hamster"],"tags":["pet"],"category":"Animals & Nature","description":"hamster","unicode_version":"6.0"},{"emoji":"🐰","aliases":["rabbit"],"tags":["bunny"],"category":"Animals & Nature","description":"rabbit face","unicode_version":"6.0"},{"emoji":"🐇","aliases":["rabbit2"],"tags":[],"category":"Animals & Nature","description":"rabbit","unicode_version":"6.0"},{"emoji":"🐿️","aliases":["chipmunk"],"tags":[],"category":"Animals & Nature","description":"chipmunk","unicode_version":"7.0"},{"emoji":"🦫","aliases":["beaver"],"tags":[],"category":"Animals & Nature","description":"beaver","unicode_version":"13.0"},{"emoji":"🦔","aliases":["hedgehog"],"tags":[],"category":"Animals & Nature","description":"hedgehog","unicode_version":"11.0"},{"emoji":"🦇","aliases":["bat"],"tags":[],"category":"Animals & Nature","description":"bat","unicode_version":"9.0"},{"emoji":"🐻","aliases":["bear"],"tags":[],"category":"Animals & Nature","description":"bear","unicode_version":"6.0"},{"emoji":"🐻‍❄️","aliases":["polar_bear"],"tags":[],"category":"Animals & Nature","description":"polar bear","unicode_version":"13.0"},{"emoji":"🐨","aliases":["koala"],"tags":[],"category":"Animals & Nature","description":"koala","unicode_version":"6.0"},{"emoji":"🐼","aliases":["panda_face"],"tags":[],"category":"Animals & Nature","description":"panda","unicode_version":"6.0"},{"emoji":"🦥","aliases":["sloth"],"tags":[],"category":"Animals & Nature","description":"sloth","unicode_version":"12.0"},{"emoji":"🦦","aliases":["otter"],"tags":[],"category":"Animals & Nature","description":"otter","unicode_version":"12.0"},{"emoji":"🦨","aliases":["skunk"],"tags":[],"category":"Animals & Nature","description":"skunk","unicode_version":"12.0"},{"emoji":"🦘","aliases":["kangaroo"],"tags":[],"category":"Animals & Nature","description":"kangaroo","unicode_version":"11.0"},{"emoji":"🦡","aliases":["badger"],"tags":[],"category":"Animals & Nature","description":"badger","unicode_version":"11.0"},{"emoji":"🐾","aliases":["feet","paw_prints"],"tags":[],"category":"Animals & Nature","description":"paw prints","unicode_version":"6.0"},{"emoji":"🦃","aliases":["turkey"],"tags":["thanksgiving"],"category":"Animals & Nature","description":"turkey","unicode_version":"8.0"},{"emoji":"🐔","aliases":["chicken"],"tags":[],"category":"Animals & Nature","description":"chicken","unicode_version":"6.0"},{"emoji":"🐓","aliases":["rooster"],"tags":[],"category":"Animals & Nature","description":"rooster","unicode_version":"6.0"},{"emoji":"🐣","aliases":["hatching_chick"],"tags":[],"category":"Animals & Nature","description":"hatching chick","unicode_version":"6.0"},{"emoji":"🐤","aliases":["baby_chick"],"tags":[],"category":"Animals & Nature","description":"baby chick","unicode_version":"6.0"},{"emoji":"🐥","aliases":["hatched_chick"],"tags":[],"category":"Animals & Nature","description":"front-facing baby chick","unicode_version":"6.0"},{"emoji":"🐦","aliases":["bird"],"tags":[],"category":"Animals & Nature","description":"bird","unicode_version":"6.0"},{"emoji":"🐧","aliases":["penguin"],"tags":[],"category":"Animals & Nature","description":"penguin","unicode_version":"6.0"},{"emoji":"🕊️","aliases":["dove"],"tags":["peace"],"category":"Animals & Nature","description":"dove","unicode_version":"7.0"},{"emoji":"🦅","aliases":["eagle"],"tags":[],"category":"Animals & Nature","description":"eagle","unicode_version":"9.0"},{"emoji":"🦆","aliases":["duck"],"tags":[],"category":"Animals & Nature","description":"duck","unicode_version":"9.0"},{"emoji":"🦢","aliases":["swan"],"tags":[],"category":"Animals & Nature","description":"swan","unicode_version":"11.0"},{"emoji":"🦉","aliases":["owl"],"tags":[],"category":"Animals & Nature","description":"owl","unicode_version":"9.0"},{"emoji":"🦤","aliases":["dodo"],"tags":[],"category":"Animals & Nature","description":"dodo","unicode_version":"13.0"},{"emoji":"🪶","aliases":["feather"],"tags":[],"category":"Animals & Nature","description":"feather","unicode_version":"13.0"},{"emoji":"🦩","aliases":["flamingo"],"tags":[],"category":"Animals & Nature","description":"flamingo","unicode_version":"12.0"},{"emoji":"🦚","aliases":["peacock"],"tags":[],"category":"Animals & Nature","description":"peacock","unicode_version":"11.0"},{"emoji":"🦜","aliases":["parrot"],"tags":[],"category":"Animals & Nature","description":"parrot","unicode_version":"11.0"},{"emoji":"🐸","aliases":["frog"],"tags":[],"category":"Animals & Nature","description":"frog","unicode_version":"6.0"},{"emoji":"🐊","aliases":["crocodile"],"tags":[],"category":"Animals & Nature","description":"crocodile","unicode_version":"6.0"},{"emoji":"🐢","aliases":["turtle"],"tags":["slow"],"category":"Animals & Nature","description":"turtle","unicode_version":"6.0"},{"emoji":"🦎","aliases":["lizard"],"tags":[],"category":"Animals & Nature","description":"lizard","unicode_version":"9.0"},{"emoji":"🐍","aliases":["snake"],"tags":[],"category":"Animals & Nature","description":"snake","unicode_version":"6.0"},{"emoji":"🐲","aliases":["dragon_face"],"tags":[],"category":"Animals & Nature","description":"dragon face","unicode_version":"6.0"},{"emoji":"🐉","aliases":["dragon"],"tags":[],"category":"Animals & Nature","description":"dragon","unicode_version":"6.0"},{"emoji":"🦕","aliases":["sauropod"],"tags":["dinosaur"],"category":"Animals & Nature","description":"sauropod","unicode_version":"11.0"},{"emoji":"🦖","aliases":["t-rex"],"tags":["dinosaur"],"category":"Animals & Nature","description":"T-Rex","unicode_version":"11.0"},{"emoji":"🐳","aliases":["whale"],"tags":["sea"],"category":"Animals & Nature","description":"spouting whale","unicode_version":"6.0"},{"emoji":"🐋","aliases":["whale2"],"tags":[],"category":"Animals & Nature","description":"whale","unicode_version":"6.0"},{"emoji":"🐬","aliases":["dolphin","flipper"],"tags":[],"category":"Animals & Nature","description":"dolphin","unicode_version":"6.0"},{"emoji":"🦭","aliases":["seal"],"tags":[],"category":"Animals & Nature","description":"seal","unicode_version":"13.0"},{"emoji":"🐟","aliases":["fish"],"tags":[],"category":"Animals & Nature","description":"fish","unicode_version":"6.0"},{"emoji":"🐠","aliases":["tropical_fish"],"tags":[],"category":"Animals & Nature","description":"tropical fish","unicode_version":"6.0"},{"emoji":"🐡","aliases":["blowfish"],"tags":[],"category":"Animals & Nature","description":"blowfish","unicode_version":"6.0"},{"emoji":"🦈","aliases":["shark"],"tags":[],"category":"Animals & Nature","description":"shark","unicode_version":"9.0"},{"emoji":"🐙","aliases":["octopus"],"tags":[],"category":"Animals & Nature","description":"octopus","unicode_version":"6.0"},{"emoji":"🐚","aliases":["shell"],"tags":["sea","beach"],"category":"Animals & Nature","description":"spiral shell","unicode_version":"6.0"},{"emoji":"🐌","aliases":["snail"],"tags":["slow"],"category":"Animals & Nature","description":"snail","unicode_version":"6.0"},{"emoji":"🦋","aliases":["butterfly"],"tags":[],"category":"Animals & Nature","description":"butterfly","unicode_version":"9.0"},{"emoji":"🐛","aliases":["bug"],"tags":[],"category":"Animals & Nature","description":"bug","unicode_version":"6.0"},{"emoji":"🐜","aliases":["ant"],"tags":[],"category":"Animals & Nature","description":"ant","unicode_version":"6.0"},{"emoji":"🐝","aliases":["bee","honeybee"],"tags":[],"category":"Animals & Nature","description":"honeybee","unicode_version":"6.0"},{"emoji":"🪲","aliases":["beetle"],"tags":[],"category":"Animals & Nature","description":"beetle","unicode_version":"13.0"},{"emoji":"🐞","aliases":["lady_beetle"],"tags":["bug"],"category":"Animals & Nature","description":"lady beetle","unicode_version":"6.0"},{"emoji":"🦗","aliases":["cricket"],"tags":[],"category":"Animals & Nature","description":"cricket","unicode_version":"11.0"},{"emoji":"🪳","aliases":["cockroach"],"tags":[],"category":"Animals & Nature","description":"cockroach","unicode_version":"13.0"},{"emoji":"🕷️","aliases":["spider"],"tags":[],"category":"Animals & Nature","description":"spider","unicode_version":"7.0"},{"emoji":"🕸️","aliases":["spider_web"],"tags":[],"category":"Animals & Nature","description":"spider web","unicode_version":"7.0"},{"emoji":"🦂","aliases":["scorpion"],"tags":[],"category":"Animals & Nature","description":"scorpion","unicode_version":"8.0"},{"emoji":"🦟","aliases":["mosquito"],"tags":[],"category":"Animals & Nature","description":"mosquito","unicode_version":"11.0"},{"emoji":"🪰","aliases":["fly"],"tags":[],"category":"Animals & Nature","description":"fly","unicode_version":"13.0"},{"emoji":"🪱","aliases":["worm"],"tags":[],"category":"Animals & Nature","description":"worm","unicode_version":"13.0"},{"emoji":"🦠","aliases":["microbe"],"tags":["germ"],"category":"Animals & Nature","description":"microbe","unicode_version":"11.0"},{"emoji":"💐","aliases":["bouquet"],"tags":["flowers"],"category":"Animals & Nature","description":"bouquet","unicode_version":"6.0"},{"emoji":"🌸","aliases":["cherry_blossom"],"tags":["flower","spring"],"category":"Animals & Nature","description":"cherry blossom","unicode_version":"6.0"},{"emoji":"💮","aliases":["white_flower"],"tags":[],"category":"Animals & Nature","description":"white flower","unicode_version":"6.0"},{"emoji":"🏵️","aliases":["rosette"],"tags":[],"category":"Animals & Nature","description":"rosette","unicode_version":"7.0"},{"emoji":"🌹","aliases":["rose"],"tags":["flower"],"category":"Animals & Nature","description":"rose","unicode_version":"6.0"},{"emoji":"🥀","aliases":["wilted_flower"],"tags":[],"category":"Animals & Nature","description":"wilted flower","unicode_version":"9.0"},{"emoji":"🌺","aliases":["hibiscus"],"tags":[],"category":"Animals & Nature","description":"hibiscus","unicode_version":"6.0"},{"emoji":"🌻","aliases":["sunflower"],"tags":[],"category":"Animals & Nature","description":"sunflower","unicode_version":"6.0"},{"emoji":"🌼","aliases":["blossom"],"tags":[],"category":"Animals & Nature","description":"blossom","unicode_version":"6.0"},{"emoji":"🌷","aliases":["tulip"],"tags":["flower"],"category":"Animals & Nature","description":"tulip","unicode_version":"6.0"},{"emoji":"🌱","aliases":["seedling"],"tags":["plant"],"category":"Animals & Nature","description":"seedling","unicode_version":"6.0"},{"emoji":"🪴","aliases":["potted_plant"],"tags":[],"category":"Animals & Nature","description":"potted plant","unicode_version":"13.0"},{"emoji":"🌲","aliases":["evergreen_tree"],"tags":["wood"],"category":"Animals & Nature","description":"evergreen tree","unicode_version":"6.0"},{"emoji":"🌳","aliases":["deciduous_tree"],"tags":["wood"],"category":"Animals & Nature","description":"deciduous tree","unicode_version":"6.0"},{"emoji":"🌴","aliases":["palm_tree"],"tags":[],"category":"Animals & Nature","description":"palm tree","unicode_version":"6.0"},{"emoji":"🌵","aliases":["cactus"],"tags":[],"category":"Animals & Nature","description":"cactus","unicode_version":"6.0"},{"emoji":"🌾","aliases":["ear_of_rice"],"tags":[],"category":"Animals & Nature","description":"sheaf of rice","unicode_version":"6.0"},{"emoji":"🌿","aliases":["herb"],"tags":[],"category":"Animals & Nature","description":"herb","unicode_version":"6.0"},{"emoji":"☘️","aliases":["shamrock"],"tags":[],"category":"Animals & Nature","description":"shamrock","unicode_version":"4.1"},{"emoji":"🍀","aliases":["four_leaf_clover"],"tags":["luck"],"category":"Animals & Nature","description":"four leaf clover","unicode_version":"6.0"},{"emoji":"🍁","aliases":["maple_leaf"],"tags":["canada"],"category":"Animals & Nature","description":"maple leaf","unicode_version":"6.0"},{"emoji":"🍂","aliases":["fallen_leaf"],"tags":["autumn"],"category":"Animals & Nature","description":"fallen leaf","unicode_version":"6.0"},{"emoji":"🍃","aliases":["leaves"],"tags":["leaf"],"category":"Animals & Nature","description":"leaf fluttering in wind","unicode_version":"6.0"},{"emoji":"🍇","aliases":["grapes"],"tags":[],"category":"Food & Drink","description":"grapes","unicode_version":"6.0"},{"emoji":"🍈","aliases":["melon"],"tags":[],"category":"Food & Drink","description":"melon","unicode_version":"6.0"},{"emoji":"🍉","aliases":["watermelon"],"tags":[],"category":"Food & Drink","description":"watermelon","unicode_version":"6.0"},{"emoji":"🍊","aliases":["tangerine","orange","mandarin"],"tags":[],"category":"Food & Drink","description":"tangerine","unicode_version":"6.0"},{"emoji":"🍋","aliases":["lemon"],"tags":[],"category":"Food & Drink","description":"lemon","unicode_version":"6.0"},{"emoji":"🍌","aliases":["banana"],"tags":["fruit"],"category":"Food & Drink","description":"banana","unicode_version":"6.0"},{"emoji":"🍍","aliases":["pineapple"],"tags":[],"category":"Food & Drink","description":"pineapple","unicode_version":"6.0"},{"emoji":"🥭","aliases":["mango"],"tags":[],"category":"Food & Drink","description":"mango","unicode_version":"11.0"},{"emoji":"🍎","aliases":["apple"],"tags":[],"category":"Food & Drink","description":"red apple","unicode_version":"6.0"},{"emoji":"🍏","aliases":["green_apple"],"tags":["fruit"],"category":"Food & Drink","description":"green apple","unicode_version":"6.0"},{"emoji":"🍐","aliases":["pear"],"tags":[],"category":"Food & Drink","description":"pear","unicode_version":"6.0"},{"emoji":"🍑","aliases":["peach"],"tags":[],"category":"Food & Drink","description":"peach","unicode_version":"6.0"},{"emoji":"🍒","aliases":["cherries"],"tags":["fruit"],"category":"Food & Drink","description":"cherries","unicode_version":"6.0"},{"emoji":"🍓","aliases":["strawberry"],"tags":["fruit"],"category":"Food & Drink","description":"strawberry","unicode_version":"6.0"},{"emoji":"🫐","aliases":["blueberries"],"tags":[],"category":"Food & Drink","description":"blueberries","unicode_version":"13.0"},{"emoji":"🥝","aliases":["kiwi_fruit"],"tags":[],"category":"Food & Drink","description":"kiwi fruit","unicode_version":"9.0"},{"emoji":"🍅","aliases":["tomato"],"tags":[],"category":"Food & Drink","description":"tomato","unicode_version":"6.0"},{"emoji":"🫒","aliases":["olive"],"tags":[],"category":"Food & Drink","description":"olive","unicode_version":"13.0"},{"emoji":"🥥","aliases":["coconut"],"tags":[],"category":"Food & Drink","description":"coconut","unicode_version":"11.0"},{"emoji":"🥑","aliases":["avocado"],"tags":[],"category":"Food & Drink","description":"avocado","unicode_version":"9.0"},{"emoji":"🍆","aliases":["eggplant"],"tags":["aubergine"],"category":"Food & Drink","description":"eggplant","unicode_version":"6.0"},{"emoji":"🥔","aliases":["potato"],"tags":[],"category":"Food & Drink","description":"potato","unicode_version":"9.0"},{"emoji":"🥕","aliases":["carrot"],"tags":[],"category":"Food & Drink","description":"carrot","unicode_version":"9.0"},{"emoji":"🌽","aliases":["corn"],"tags":[],"category":"Food & Drink","description":"ear of corn","unicode_version":"6.0"},{"emoji":"🌶️","aliases":["hot_pepper"],"tags":["spicy"],"category":"Food & Drink","description":"hot pepper","unicode_version":"7.0"},{"emoji":"🫑","aliases":["bell_pepper"],"tags":[],"category":"Food & Drink","description":"bell pepper","unicode_version":"13.0"},{"emoji":"🥒","aliases":["cucumber"],"tags":[],"category":"Food & Drink","description":"cucumber","unicode_version":"9.0"},{"emoji":"🥬","aliases":["leafy_green"],"tags":[],"category":"Food & Drink","description":"leafy green","unicode_version":"11.0"},{"emoji":"🥦","aliases":["broccoli"],"tags":[],"category":"Food & Drink","description":"broccoli","unicode_version":"11.0"},{"emoji":"🧄","aliases":["garlic"],"tags":[],"category":"Food & Drink","description":"garlic","unicode_version":"12.0"},{"emoji":"🧅","aliases":["onion"],"tags":[],"category":"Food & Drink","description":"onion","unicode_version":"12.0"},{"emoji":"🍄","aliases":["mushroom"],"tags":[],"category":"Food & Drink","description":"mushroom","unicode_version":"6.0"},{"emoji":"🥜","aliases":["peanuts"],"tags":[],"category":"Food & Drink","description":"peanuts","unicode_version":"9.0"},{"emoji":"🌰","aliases":["chestnut"],"tags":[],"category":"Food & Drink","description":"chestnut","unicode_version":"6.0"},{"emoji":"🍞","aliases":["bread"],"tags":["toast"],"category":"Food & Drink","description":"bread","unicode_version":"6.0"},{"emoji":"🥐","aliases":["croissant"],"tags":[],"category":"Food & Drink","description":"croissant","unicode_version":"9.0"},{"emoji":"🥖","aliases":["baguette_bread"],"tags":[],"category":"Food & Drink","description":"baguette bread","unicode_version":"9.0"},{"emoji":"🫓","aliases":["flatbread"],"tags":[],"category":"Food & Drink","description":"flatbread","unicode_version":"13.0"},{"emoji":"🥨","aliases":["pretzel"],"tags":[],"category":"Food & Drink","description":"pretzel","unicode_version":"11.0"},{"emoji":"🥯","aliases":["bagel"],"tags":[],"category":"Food & Drink","description":"bagel","unicode_version":"11.0"},{"emoji":"🥞","aliases":["pancakes"],"tags":[],"category":"Food & Drink","description":"pancakes","unicode_version":"9.0"},{"emoji":"🧇","aliases":["waffle"],"tags":[],"category":"Food & Drink","description":"waffle","unicode_version":"12.0"},{"emoji":"🧀","aliases":["cheese"],"tags":[],"category":"Food & Drink","description":"cheese wedge","unicode_version":"8.0"},{"emoji":"🍖","aliases":["meat_on_bone"],"tags":[],"category":"Food & Drink","description":"meat on bone","unicode_version":"6.0"},{"emoji":"🍗","aliases":["poultry_leg"],"tags":["meat","chicken"],"category":"Food & Drink","description":"poultry leg","unicode_version":"6.0"},{"emoji":"🥩","aliases":["cut_of_meat"],"tags":[],"category":"Food & Drink","description":"cut of meat","unicode_version":"11.0"},{"emoji":"🥓","aliases":["bacon"],"tags":[],"category":"Food & Drink","description":"bacon","unicode_version":"9.0"},{"emoji":"🍔","aliases":["hamburger"],"tags":["burger"],"category":"Food & Drink","description":"hamburger","unicode_version":"6.0"},{"emoji":"🍟","aliases":["fries"],"tags":[],"category":"Food & Drink","description":"french fries","unicode_version":"6.0"},{"emoji":"🍕","aliases":["pizza"],"tags":[],"category":"Food & Drink","description":"pizza","unicode_version":"6.0"},{"emoji":"🌭","aliases":["hotdog"],"tags":[],"category":"Food & Drink","description":"hot dog","unicode_version":"8.0"},{"emoji":"🥪","aliases":["sandwich"],"tags":[],"category":"Food & Drink","description":"sandwich","unicode_version":"11.0"},{"emoji":"🌮","aliases":["taco"],"tags":[],"category":"Food & Drink","description":"taco","unicode_version":"8.0"},{"emoji":"🌯","aliases":["burrito"],"tags":[],"category":"Food & Drink","description":"burrito","unicode_version":"8.0"},{"emoji":"🫔","aliases":["tamale"],"tags":[],"category":"Food & Drink","description":"tamale","unicode_version":"13.0"},{"emoji":"🥙","aliases":["stuffed_flatbread"],"tags":[],"category":"Food & Drink","description":"stuffed flatbread","unicode_version":"9.0"},{"emoji":"🧆","aliases":["falafel"],"tags":[],"category":"Food & Drink","description":"falafel","unicode_version":"12.0"},{"emoji":"🥚","aliases":["egg"],"tags":[],"category":"Food & Drink","description":"egg","unicode_version":"9.0"},{"emoji":"🍳","aliases":["fried_egg"],"tags":["breakfast"],"category":"Food & Drink","description":"cooking","unicode_version":"6.0"},{"emoji":"🥘","aliases":["shallow_pan_of_food"],"tags":["paella","curry"],"category":"Food & Drink","description":"shallow pan of food","unicode_version":""},{"emoji":"🍲","aliases":["stew"],"tags":[],"category":"Food & Drink","description":"pot of food","unicode_version":"6.0"},{"emoji":"🫕","aliases":["fondue"],"tags":[],"category":"Food & Drink","description":"fondue","unicode_version":"13.0"},{"emoji":"🥣","aliases":["bowl_with_spoon"],"tags":[],"category":"Food & Drink","description":"bowl with spoon","unicode_version":"11.0"},{"emoji":"🥗","aliases":["green_salad"],"tags":[],"category":"Food & Drink","description":"green salad","unicode_version":"9.0"},{"emoji":"🍿","aliases":["popcorn"],"tags":[],"category":"Food & Drink","description":"popcorn","unicode_version":"8.0"},{"emoji":"🧈","aliases":["butter"],"tags":[],"category":"Food & Drink","description":"butter","unicode_version":"12.0"},{"emoji":"🧂","aliases":["salt"],"tags":[],"category":"Food & Drink","description":"salt","unicode_version":"11.0"},{"emoji":"🥫","aliases":["canned_food"],"tags":[],"category":"Food & Drink","description":"canned food","unicode_version":"11.0"},{"emoji":"🍱","aliases":["bento"],"tags":[],"category":"Food & Drink","description":"bento box","unicode_version":"6.0"},{"emoji":"🍘","aliases":["rice_cracker"],"tags":[],"category":"Food & Drink","description":"rice cracker","unicode_version":"6.0"},{"emoji":"🍙","aliases":["rice_ball"],"tags":[],"category":"Food & Drink","description":"rice ball","unicode_version":"6.0"},{"emoji":"🍚","aliases":["rice"],"tags":[],"category":"Food & Drink","description":"cooked rice","unicode_version":"6.0"},{"emoji":"🍛","aliases":["curry"],"tags":[],"category":"Food & Drink","description":"curry rice","unicode_version":"6.0"},{"emoji":"🍜","aliases":["ramen"],"tags":["noodle"],"category":"Food & Drink","description":"steaming bowl","unicode_version":"6.0"},{"emoji":"🍝","aliases":["spaghetti"],"tags":["pasta"],"category":"Food & Drink","description":"spaghetti","unicode_version":"6.0"},{"emoji":"🍠","aliases":["sweet_potato"],"tags":[],"category":"Food & Drink","description":"roasted sweet potato","unicode_version":"6.0"},{"emoji":"🍢","aliases":["oden"],"tags":[],"category":"Food & Drink","description":"oden","unicode_version":"6.0"},{"emoji":"🍣","aliases":["sushi"],"tags":[],"category":"Food & Drink","description":"sushi","unicode_version":"6.0"},{"emoji":"🍤","aliases":["fried_shrimp"],"tags":["tempura"],"category":"Food & Drink","description":"fried shrimp","unicode_version":"6.0"},{"emoji":"🍥","aliases":["fish_cake"],"tags":[],"category":"Food & Drink","description":"fish cake with swirl","unicode_version":"6.0"},{"emoji":"🥮","aliases":["moon_cake"],"tags":[],"category":"Food & Drink","description":"moon cake","unicode_version":"11.0"},{"emoji":"🍡","aliases":["dango"],"tags":[],"category":"Food & Drink","description":"dango","unicode_version":"6.0"},{"emoji":"🥟","aliases":["dumpling"],"tags":[],"category":"Food & Drink","description":"dumpling","unicode_version":"11.0"},{"emoji":"🥠","aliases":["fortune_cookie"],"tags":[],"category":"Food & Drink","description":"fortune cookie","unicode_version":"11.0"},{"emoji":"🥡","aliases":["takeout_box"],"tags":[],"category":"Food & Drink","description":"takeout box","unicode_version":"11.0"},{"emoji":"🦀","aliases":["crab"],"tags":[],"category":"Food & Drink","description":"crab","unicode_version":"8.0"},{"emoji":"🦞","aliases":["lobster"],"tags":[],"category":"Food & Drink","description":"lobster","unicode_version":"11.0"},{"emoji":"🦐","aliases":["shrimp"],"tags":[],"category":"Food & Drink","description":"shrimp","unicode_version":"9.0"},{"emoji":"🦑","aliases":["squid"],"tags":[],"category":"Food & Drink","description":"squid","unicode_version":"9.0"},{"emoji":"🦪","aliases":["oyster"],"tags":[],"category":"Food & Drink","description":"oyster","unicode_version":"12.0"},{"emoji":"🍦","aliases":["icecream"],"tags":[],"category":"Food & Drink","description":"soft ice cream","unicode_version":"6.0"},{"emoji":"🍧","aliases":["shaved_ice"],"tags":[],"category":"Food & Drink","description":"shaved ice","unicode_version":"6.0"},{"emoji":"🍨","aliases":["ice_cream"],"tags":[],"category":"Food & Drink","description":"ice cream","unicode_version":"6.0"},{"emoji":"🍩","aliases":["doughnut"],"tags":[],"category":"Food & Drink","description":"doughnut","unicode_version":"6.0"},{"emoji":"🍪","aliases":["cookie"],"tags":[],"category":"Food & Drink","description":"cookie","unicode_version":"6.0"},{"emoji":"🎂","aliases":["birthday"],"tags":["party"],"category":"Food & Drink","description":"birthday cake","unicode_version":"6.0"},{"emoji":"🍰","aliases":["cake"],"tags":["dessert"],"category":"Food & Drink","description":"shortcake","unicode_version":"6.0"},{"emoji":"🧁","aliases":["cupcake"],"tags":[],"category":"Food & Drink","description":"cupcake","unicode_version":"11.0"},{"emoji":"🥧","aliases":["pie"],"tags":[],"category":"Food & Drink","description":"pie","unicode_version":"11.0"},{"emoji":"🍫","aliases":["chocolate_bar"],"tags":[],"category":"Food & Drink","description":"chocolate bar","unicode_version":"6.0"},{"emoji":"🍬","aliases":["candy"],"tags":["sweet"],"category":"Food & Drink","description":"candy","unicode_version":"6.0"},{"emoji":"🍭","aliases":["lollipop"],"tags":[],"category":"Food & Drink","description":"lollipop","unicode_version":"6.0"},{"emoji":"🍮","aliases":["custard"],"tags":[],"category":"Food & Drink","description":"custard","unicode_version":"6.0"},{"emoji":"🍯","aliases":["honey_pot"],"tags":[],"category":"Food & Drink","description":"honey pot","unicode_version":"6.0"},{"emoji":"🍼","aliases":["baby_bottle"],"tags":["milk"],"category":"Food & Drink","description":"baby bottle","unicode_version":"6.0"},{"emoji":"🥛","aliases":["milk_glass"],"tags":[],"category":"Food & Drink","description":"glass of milk","unicode_version":"9.0"},{"emoji":"☕","aliases":["coffee"],"tags":["cafe","espresso"],"category":"Food & Drink","description":"hot beverage","unicode_version":"4.0"},{"emoji":"🫖","aliases":["teapot"],"tags":[],"category":"Food & Drink","description":"teapot","unicode_version":"13.0"},{"emoji":"🍵","aliases":["tea"],"tags":["green","breakfast"],"category":"Food & Drink","description":"teacup without handle","unicode_version":"6.0"},{"emoji":"🍶","aliases":["sake"],"tags":[],"category":"Food & Drink","description":"sake","unicode_version":"6.0"},{"emoji":"🍾","aliases":["champagne"],"tags":["bottle","bubbly","celebration"],"category":"Food & Drink","description":"bottle with popping cork","unicode_version":"8.0"},{"emoji":"🍷","aliases":["wine_glass"],"tags":[],"category":"Food & Drink","description":"wine glass","unicode_version":"6.0"},{"emoji":"🍸","aliases":["cocktail"],"tags":["drink"],"category":"Food & Drink","description":"cocktail glass","unicode_version":"6.0"},{"emoji":"🍹","aliases":["tropical_drink"],"tags":["summer","vacation"],"category":"Food & Drink","description":"tropical drink","unicode_version":"6.0"},{"emoji":"🍺","aliases":["beer"],"tags":["drink"],"category":"Food & Drink","description":"beer mug","unicode_version":"6.0"},{"emoji":"🍻","aliases":["beers"],"tags":["drinks"],"category":"Food & Drink","description":"clinking beer mugs","unicode_version":"6.0"},{"emoji":"🥂","aliases":["clinking_glasses"],"tags":["cheers","toast"],"category":"Food & Drink","description":"clinking glasses","unicode_version":"9.0"},{"emoji":"🥃","aliases":["tumbler_glass"],"tags":["whisky"],"category":"Food & Drink","description":"tumbler glass","unicode_version":"9.0"},{"emoji":"🥤","aliases":["cup_with_straw"],"tags":[],"category":"Food & Drink","description":"cup with straw","unicode_version":"11.0"},{"emoji":"🧋","aliases":["bubble_tea"],"tags":[],"category":"Food & Drink","description":"bubble tea","unicode_version":"13.0"},{"emoji":"🧃","aliases":["beverage_box"],"tags":[],"category":"Food & Drink","description":"beverage box","unicode_version":"12.0"},{"emoji":"🧉","aliases":["mate"],"tags":[],"category":"Food & Drink","description":"mate","unicode_version":"12.0"},{"emoji":"🧊","aliases":["ice_cube"],"tags":[],"category":"Food & Drink","description":"ice","unicode_version":"12.0"},{"emoji":"🥢","aliases":["chopsticks"],"tags":[],"category":"Food & Drink","description":"chopsticks","unicode_version":"11.0"},{"emoji":"🍽️","aliases":["plate_with_cutlery"],"tags":["dining","dinner"],"category":"Food & Drink","description":"fork and knife with plate","unicode_version":"7.0"},{"emoji":"🍴","aliases":["fork_and_knife"],"tags":["cutlery"],"category":"Food & Drink","description":"fork and knife","unicode_version":"6.0"},{"emoji":"🥄","aliases":["spoon"],"tags":[],"category":"Food & Drink","description":"spoon","unicode_version":"9.0"},{"emoji":"🔪","aliases":["hocho","knife"],"tags":["cut","chop"],"category":"Food & Drink","description":"kitchen knife","unicode_version":"6.0"},{"emoji":"🏺","aliases":["amphora"],"tags":[],"category":"Food & Drink","description":"amphora","unicode_version":"8.0"},{"emoji":"🌍","aliases":["earth_africa"],"tags":["globe","world","international"],"category":"Travel & Places","description":"globe showing Europe-Africa","unicode_version":"6.0"},{"emoji":"🌎","aliases":["earth_americas"],"tags":["globe","world","international"],"category":"Travel & Places","description":"globe showing Americas","unicode_version":"6.0"},{"emoji":"🌏","aliases":["earth_asia"],"tags":["globe","world","international"],"category":"Travel & Places","description":"globe showing Asia-Australia","unicode_version":"6.0"},{"emoji":"🌐","aliases":["globe_with_meridians"],"tags":["world","global","international"],"category":"Travel & Places","description":"globe with meridians","unicode_version":"6.0"},{"emoji":"🗺️","aliases":["world_map"],"tags":["travel"],"category":"Travel & Places","description":"world map","unicode_version":"7.0"},{"emoji":"🗾","aliases":["japan"],"tags":[],"category":"Travel & Places","description":"map of Japan","unicode_version":"6.0"},{"emoji":"🧭","aliases":["compass"],"tags":[],"category":"Travel & Places","description":"compass","unicode_version":"11.0"},{"emoji":"🏔️","aliases":["mountain_snow"],"tags":[],"category":"Travel & Places","description":"snow-capped mountain","unicode_version":"7.0"},{"emoji":"⛰️","aliases":["mountain"],"tags":[],"category":"Travel & Places","description":"mountain","unicode_version":"5.2"},{"emoji":"🌋","aliases":["volcano"],"tags":[],"category":"Travel & Places","description":"volcano","unicode_version":"6.0"},{"emoji":"🗻","aliases":["mount_fuji"],"tags":[],"category":"Travel & Places","description":"mount fuji","unicode_version":"6.0"},{"emoji":"🏕️","aliases":["camping"],"tags":[],"category":"Travel & Places","description":"camping","unicode_version":"7.0"},{"emoji":"🏖️","aliases":["beach_umbrella"],"tags":[],"category":"Travel & Places","description":"beach with umbrella","unicode_version":"7.0"},{"emoji":"🏜️","aliases":["desert"],"tags":[],"category":"Travel & Places","description":"desert","unicode_version":"7.0"},{"emoji":"🏝️","aliases":["desert_island"],"tags":[],"category":"Travel & Places","description":"desert island","unicode_version":"7.0"},{"emoji":"🏞️","aliases":["national_park"],"tags":[],"category":"Travel & Places","description":"national park","unicode_version":"7.0"},{"emoji":"🏟️","aliases":["stadium"],"tags":[],"category":"Travel & Places","description":"stadium","unicode_version":"7.0"},{"emoji":"🏛️","aliases":["classical_building"],"tags":[],"category":"Travel & Places","description":"classical building","unicode_version":"7.0"},{"emoji":"🏗️","aliases":["building_construction"],"tags":[],"category":"Travel & Places","description":"building construction","unicode_version":"7.0"},{"emoji":"🧱","aliases":["bricks"],"tags":[],"category":"Travel & Places","description":"brick","unicode_version":"11.0"},{"emoji":"🪨","aliases":["rock"],"tags":[],"category":"Travel & Places","description":"rock","unicode_version":"13.0"},{"emoji":"🪵","aliases":["wood"],"tags":[],"category":"Travel & Places","description":"wood","unicode_version":"13.0"},{"emoji":"🛖","aliases":["hut"],"tags":[],"category":"Travel & Places","description":"hut","unicode_version":"13.0"},{"emoji":"🏘️","aliases":["houses"],"tags":[],"category":"Travel & Places","description":"houses","unicode_version":"7.0"},{"emoji":"🏚️","aliases":["derelict_house"],"tags":[],"category":"Travel & Places","description":"derelict house","unicode_version":"7.0"},{"emoji":"🏠","aliases":["house"],"tags":[],"category":"Travel & Places","description":"house","unicode_version":"6.0"},{"emoji":"🏡","aliases":["house_with_garden"],"tags":[],"category":"Travel & Places","description":"house with garden","unicode_version":"6.0"},{"emoji":"🏢","aliases":["office"],"tags":[],"category":"Travel & Places","description":"office building","unicode_version":"6.0"},{"emoji":"🏣","aliases":["post_office"],"tags":[],"category":"Travel & Places","description":"Japanese post office","unicode_version":"6.0"},{"emoji":"🏤","aliases":["european_post_office"],"tags":[],"category":"Travel & Places","description":"post office","unicode_version":"6.0"},{"emoji":"🏥","aliases":["hospital"],"tags":[],"category":"Travel & Places","description":"hospital","unicode_version":"6.0"},{"emoji":"🏦","aliases":["bank"],"tags":[],"category":"Travel & Places","description":"bank","unicode_version":"6.0"},{"emoji":"🏨","aliases":["hotel"],"tags":[],"category":"Travel & Places","description":"hotel","unicode_version":"6.0"},{"emoji":"🏩","aliases":["love_hotel"],"tags":[],"category":"Travel & Places","description":"love hotel","unicode_version":"6.0"},{"emoji":"🏪","aliases":["convenience_store"],"tags":[],"category":"Travel & Places","description":"convenience store","unicode_version":"6.0"},{"emoji":"🏫","aliases":["school"],"tags":[],"category":"Travel & Places","description":"school","unicode_version":"6.0"},{"emoji":"🏬","aliases":["department_store"],"tags":[],"category":"Travel & Places","description":"department store","unicode_version":"6.0"},{"emoji":"🏭","aliases":["factory"],"tags":[],"category":"Travel & Places","description":"factory","unicode_version":"6.0"},{"emoji":"🏯","aliases":["japanese_castle"],"tags":[],"category":"Travel & Places","description":"Japanese castle","unicode_version":"6.0"},{"emoji":"🏰","aliases":["european_castle"],"tags":[],"category":"Travel & Places","description":"castle","unicode_version":"6.0"},{"emoji":"💒","aliases":["wedding"],"tags":["marriage"],"category":"Travel & Places","description":"wedding","unicode_version":"6.0"},{"emoji":"🗼","aliases":["tokyo_tower"],"tags":[],"category":"Travel & Places","description":"Tokyo tower","unicode_version":"6.0"},{"emoji":"🗽","aliases":["statue_of_liberty"],"tags":[],"category":"Travel & Places","description":"Statue of Liberty","unicode_version":"6.0"},{"emoji":"⛪","aliases":["church"],"tags":[],"category":"Travel & Places","description":"church","unicode_version":"5.2"},{"emoji":"🕌","aliases":["mosque"],"tags":[],"category":"Travel & Places","description":"mosque","unicode_version":"8.0"},{"emoji":"🛕","aliases":["hindu_temple"],"tags":[],"category":"Travel & Places","description":"hindu temple","unicode_version":"12.0"},{"emoji":"🕍","aliases":["synagogue"],"tags":[],"category":"Travel & Places","description":"synagogue","unicode_version":"8.0"},{"emoji":"⛩️","aliases":["shinto_shrine"],"tags":[],"category":"Travel & Places","description":"shinto shrine","unicode_version":"5.2"},{"emoji":"🕋","aliases":["kaaba"],"tags":[],"category":"Travel & Places","description":"kaaba","unicode_version":"8.0"},{"emoji":"⛲","aliases":["fountain"],"tags":[],"category":"Travel & Places","description":"fountain","unicode_version":"5.2"},{"emoji":"⛺","aliases":["tent"],"tags":["camping"],"category":"Travel & Places","description":"tent","unicode_version":"5.2"},{"emoji":"🌁","aliases":["foggy"],"tags":["karl"],"category":"Travel & Places","description":"foggy","unicode_version":"6.0"},{"emoji":"🌃","aliases":["night_with_stars"],"tags":[],"category":"Travel & Places","description":"night with stars","unicode_version":"6.0"},{"emoji":"🏙️","aliases":["cityscape"],"tags":["skyline"],"category":"Travel & Places","description":"cityscape","unicode_version":"7.0"},{"emoji":"🌄","aliases":["sunrise_over_mountains"],"tags":[],"category":"Travel & Places","description":"sunrise over mountains","unicode_version":"6.0"},{"emoji":"🌅","aliases":["sunrise"],"tags":[],"category":"Travel & Places","description":"sunrise","unicode_version":"6.0"},{"emoji":"🌆","aliases":["city_sunset"],"tags":[],"category":"Travel & Places","description":"cityscape at dusk","unicode_version":"6.0"},{"emoji":"🌇","aliases":["city_sunrise"],"tags":[],"category":"Travel & Places","description":"sunset","unicode_version":"6.0"},{"emoji":"🌉","aliases":["bridge_at_night"],"tags":[],"category":"Travel & Places","description":"bridge at night","unicode_version":"6.0"},{"emoji":"♨️","aliases":["hotsprings"],"tags":[],"category":"Travel & Places","description":"hot springs","unicode_version":""},{"emoji":"🎠","aliases":["carousel_horse"],"tags":[],"category":"Travel & Places","description":"carousel horse","unicode_version":"6.0"},{"emoji":"🎡","aliases":["ferris_wheel"],"tags":[],"category":"Travel & Places","description":"ferris wheel","unicode_version":"6.0"},{"emoji":"🎢","aliases":["roller_coaster"],"tags":[],"category":"Travel & Places","description":"roller coaster","unicode_version":"6.0"},{"emoji":"💈","aliases":["barber"],"tags":[],"category":"Travel & Places","description":"barber pole","unicode_version":"6.0"},{"emoji":"🎪","aliases":["circus_tent"],"tags":[],"category":"Travel & Places","description":"circus tent","unicode_version":"6.0"},{"emoji":"🚂","aliases":["steam_locomotive"],"tags":["train"],"category":"Travel & Places","description":"locomotive","unicode_version":"6.0"},{"emoji":"🚃","aliases":["railway_car"],"tags":[],"category":"Travel & Places","description":"railway car","unicode_version":"6.0"},{"emoji":"🚄","aliases":["bullettrain_side"],"tags":["train"],"category":"Travel & Places","description":"high-speed train","unicode_version":"6.0"},{"emoji":"🚅","aliases":["bullettrain_front"],"tags":["train"],"category":"Travel & Places","description":"bullet train","unicode_version":"6.0"},{"emoji":"🚆","aliases":["train2"],"tags":[],"category":"Travel & Places","description":"train","unicode_version":"6.0"},{"emoji":"🚇","aliases":["metro"],"tags":[],"category":"Travel & Places","description":"metro","unicode_version":"6.0"},{"emoji":"🚈","aliases":["light_rail"],"tags":[],"category":"Travel & Places","description":"light rail","unicode_version":"6.0"},{"emoji":"🚉","aliases":["station"],"tags":[],"category":"Travel & Places","description":"station","unicode_version":"6.0"},{"emoji":"🚊","aliases":["tram"],"tags":[],"category":"Travel & Places","description":"tram","unicode_version":"6.0"},{"emoji":"🚝","aliases":["monorail"],"tags":[],"category":"Travel & Places","description":"monorail","unicode_version":"6.0"},{"emoji":"🚞","aliases":["mountain_railway"],"tags":[],"category":"Travel & Places","description":"mountain railway","unicode_version":"6.0"},{"emoji":"🚋","aliases":["train"],"tags":[],"category":"Travel & Places","description":"tram car","unicode_version":"6.0"},{"emoji":"🚌","aliases":["bus"],"tags":[],"category":"Travel & Places","description":"bus","unicode_version":"6.0"},{"emoji":"🚍","aliases":["oncoming_bus"],"tags":[],"category":"Travel & Places","description":"oncoming bus","unicode_version":"6.0"},{"emoji":"🚎","aliases":["trolleybus"],"tags":[],"category":"Travel & Places","description":"trolleybus","unicode_version":"6.0"},{"emoji":"🚐","aliases":["minibus"],"tags":[],"category":"Travel & Places","description":"minibus","unicode_version":"6.0"},{"emoji":"🚑","aliases":["ambulance"],"tags":[],"category":"Travel & Places","description":"ambulance","unicode_version":"6.0"},{"emoji":"🚒","aliases":["fire_engine"],"tags":[],"category":"Travel & Places","description":"fire engine","unicode_version":"6.0"},{"emoji":"🚓","aliases":["police_car"],"tags":[],"category":"Travel & Places","description":"police car","unicode_version":"6.0"},{"emoji":"🚔","aliases":["oncoming_police_car"],"tags":[],"category":"Travel & Places","description":"oncoming police car","unicode_version":"6.0"},{"emoji":"🚕","aliases":["taxi"],"tags":[],"category":"Travel & Places","description":"taxi","unicode_version":"6.0"},{"emoji":"🚖","aliases":["oncoming_taxi"],"tags":[],"category":"Travel & Places","description":"oncoming taxi","unicode_version":"6.0"},{"emoji":"🚗","aliases":["car","red_car"],"tags":[],"category":"Travel & Places","description":"automobile","unicode_version":"6.0"},{"emoji":"🚘","aliases":["oncoming_automobile"],"tags":[],"category":"Travel & Places","description":"oncoming automobile","unicode_version":"6.0"},{"emoji":"🚙","aliases":["blue_car"],"tags":[],"category":"Travel & Places","description":"sport utility vehicle","unicode_version":"6.0"},{"emoji":"🛻","aliases":["pickup_truck"],"tags":[],"category":"Travel & Places","description":"pickup truck","unicode_version":"13.0"},{"emoji":"🚚","aliases":["truck"],"tags":[],"category":"Travel & Places","description":"delivery truck","unicode_version":"6.0"},{"emoji":"🚛","aliases":["articulated_lorry"],"tags":[],"category":"Travel & Places","description":"articulated lorry","unicode_version":"6.0"},{"emoji":"🚜","aliases":["tractor"],"tags":[],"category":"Travel & Places","description":"tractor","unicode_version":"6.0"},{"emoji":"🏎️","aliases":["racing_car"],"tags":[],"category":"Travel & Places","description":"racing car","unicode_version":"7.0"},{"emoji":"🏍️","aliases":["motorcycle"],"tags":[],"category":"Travel & Places","description":"motorcycle","unicode_version":"7.0"},{"emoji":"🛵","aliases":["motor_scooter"],"tags":[],"category":"Travel & Places","description":"motor scooter","unicode_version":"9.0"},{"emoji":"🦽","aliases":["manual_wheelchair"],"tags":[],"category":"Travel & Places","description":"manual wheelchair","unicode_version":"12.0"},{"emoji":"🦼","aliases":["motorized_wheelchair"],"tags":[],"category":"Travel & Places","description":"motorized wheelchair","unicode_version":"12.0"},{"emoji":"🛺","aliases":["auto_rickshaw"],"tags":[],"category":"Travel & Places","description":"auto rickshaw","unicode_version":"12.0"},{"emoji":"🚲","aliases":["bike"],"tags":["bicycle"],"category":"Travel & Places","description":"bicycle","unicode_version":"6.0"},{"emoji":"🛴","aliases":["kick_scooter"],"tags":[],"category":"Travel & Places","description":"kick scooter","unicode_version":"9.0"},{"emoji":"🛹","aliases":["skateboard"],"tags":[],"category":"Travel & Places","description":"skateboard","unicode_version":"11.0"},{"emoji":"🛼","aliases":["roller_skate"],"tags":[],"category":"Travel & Places","description":"roller skate","unicode_version":"13.0"},{"emoji":"🚏","aliases":["busstop"],"tags":[],"category":"Travel & Places","description":"bus stop","unicode_version":"6.0"},{"emoji":"🛣️","aliases":["motorway"],"tags":[],"category":"Travel & Places","description":"motorway","unicode_version":"7.0"},{"emoji":"🛤️","aliases":["railway_track"],"tags":[],"category":"Travel & Places","description":"railway track","unicode_version":"7.0"},{"emoji":"🛢️","aliases":["oil_drum"],"tags":[],"category":"Travel & Places","description":"oil drum","unicode_version":"7.0"},{"emoji":"⛽","aliases":["fuelpump"],"tags":[],"category":"Travel & Places","description":"fuel pump","unicode_version":"5.2"},{"emoji":"🚨","aliases":["rotating_light"],"tags":["911","emergency"],"category":"Travel & Places","description":"police car light","unicode_version":"6.0"},{"emoji":"🚥","aliases":["traffic_light"],"tags":[],"category":"Travel & Places","description":"horizontal traffic light","unicode_version":"6.0"},{"emoji":"🚦","aliases":["vertical_traffic_light"],"tags":["semaphore"],"category":"Travel & Places","description":"vertical traffic light","unicode_version":"6.0"},{"emoji":"🛑","aliases":["stop_sign"],"tags":[],"category":"Travel & Places","description":"stop sign","unicode_version":"9.0"},{"emoji":"🚧","aliases":["construction"],"tags":["wip"],"category":"Travel & Places","description":"construction","unicode_version":"6.0"},{"emoji":"⚓","aliases":["anchor"],"tags":["ship"],"category":"Travel & Places","description":"anchor","unicode_version":"4.1"},{"emoji":"⛵","aliases":["boat","sailboat"],"tags":[],"category":"Travel & Places","description":"sailboat","unicode_version":"5.2"},{"emoji":"🛶","aliases":["canoe"],"tags":[],"category":"Travel & Places","description":"canoe","unicode_version":"9.0"},{"emoji":"🚤","aliases":["speedboat"],"tags":["ship"],"category":"Travel & Places","description":"speedboat","unicode_version":"6.0"},{"emoji":"🛳️","aliases":["passenger_ship"],"tags":["cruise"],"category":"Travel & Places","description":"passenger ship","unicode_version":"7.0"},{"emoji":"⛴️","aliases":["ferry"],"tags":[],"category":"Travel & Places","description":"ferry","unicode_version":"5.2"},{"emoji":"🛥️","aliases":["motor_boat"],"tags":[],"category":"Travel & Places","description":"motor boat","unicode_version":"7.0"},{"emoji":"🚢","aliases":["ship"],"tags":[],"category":"Travel & Places","description":"ship","unicode_version":"6.0"},{"emoji":"✈️","aliases":["airplane"],"tags":["flight"],"category":"Travel & Places","description":"airplane","unicode_version":""},{"emoji":"🛩️","aliases":["small_airplane"],"tags":["flight"],"category":"Travel & Places","description":"small airplane","unicode_version":"7.0"},{"emoji":"🛫","aliases":["flight_departure"],"tags":[],"category":"Travel & Places","description":"airplane departure","unicode_version":"7.0"},{"emoji":"🛬","aliases":["flight_arrival"],"tags":[],"category":"Travel & Places","description":"airplane arrival","unicode_version":"7.0"},{"emoji":"🪂","aliases":["parachute"],"tags":[],"category":"Travel & Places","description":"parachute","unicode_version":"12.0"},{"emoji":"💺","aliases":["seat"],"tags":[],"category":"Travel & Places","description":"seat","unicode_version":"6.0"},{"emoji":"🚁","aliases":["helicopter"],"tags":[],"category":"Travel & Places","description":"helicopter","unicode_version":"6.0"},{"emoji":"🚟","aliases":["suspension_railway"],"tags":[],"category":"Travel & Places","description":"suspension railway","unicode_version":"6.0"},{"emoji":"🚠","aliases":["mountain_cableway"],"tags":[],"category":"Travel & Places","description":"mountain cableway","unicode_version":"6.0"},{"emoji":"🚡","aliases":["aerial_tramway"],"tags":[],"category":"Travel & Places","description":"aerial tramway","unicode_version":"6.0"},{"emoji":"🛰️","aliases":["artificial_satellite"],"tags":["orbit","space"],"category":"Travel & Places","description":"satellite","unicode_version":"7.0"},{"emoji":"🚀","aliases":["rocket"],"tags":["ship","launch"],"category":"Travel & Places","description":"rocket","unicode_version":"6.0"},{"emoji":"🛸","aliases":["flying_saucer"],"tags":["ufo"],"category":"Travel & Places","description":"flying saucer","unicode_version":"11.0"},{"emoji":"🛎️","aliases":["bellhop_bell"],"tags":[],"category":"Travel & Places","description":"bellhop bell","unicode_version":"7.0"},{"emoji":"🧳","aliases":["luggage"],"tags":[],"category":"Travel & Places","description":"luggage","unicode_version":"11.0"},{"emoji":"⌛","aliases":["hourglass"],"tags":["time"],"category":"Travel & Places","description":"hourglass done","unicode_version":""},{"emoji":"⏳","aliases":["hourglass_flowing_sand"],"tags":["time"],"category":"Travel & Places","description":"hourglass not done","unicode_version":"6.0"},{"emoji":"⌚","aliases":["watch"],"tags":["time"],"category":"Travel & Places","description":"watch","unicode_version":""},{"emoji":"⏰","aliases":["alarm_clock"],"tags":["morning"],"category":"Travel & Places","description":"alarm clock","unicode_version":"6.0"},{"emoji":"⏱️","aliases":["stopwatch"],"tags":[],"category":"Travel & Places","description":"stopwatch","unicode_version":"6.0"},{"emoji":"⏲️","aliases":["timer_clock"],"tags":[],"category":"Travel & Places","description":"timer clock","unicode_version":"6.0"},{"emoji":"🕰️","aliases":["mantelpiece_clock"],"tags":[],"category":"Travel & Places","description":"mantelpiece clock","unicode_version":"7.0"},{"emoji":"🕛","aliases":["clock12"],"tags":[],"category":"Travel & Places","description":"twelve o’clock","unicode_version":"6.0"},{"emoji":"🕧","aliases":["clock1230"],"tags":[],"category":"Travel & Places","description":"twelve-thirty","unicode_version":"6.0"},{"emoji":"🕐","aliases":["clock1"],"tags":[],"category":"Travel & Places","description":"one o’clock","unicode_version":"6.0"},{"emoji":"🕜","aliases":["clock130"],"tags":[],"category":"Travel & Places","description":"one-thirty","unicode_version":"6.0"},{"emoji":"🕑","aliases":["clock2"],"tags":[],"category":"Travel & Places","description":"two o’clock","unicode_version":"6.0"},{"emoji":"🕝","aliases":["clock230"],"tags":[],"category":"Travel & Places","description":"two-thirty","unicode_version":"6.0"},{"emoji":"🕒","aliases":["clock3"],"tags":[],"category":"Travel & Places","description":"three o’clock","unicode_version":"6.0"},{"emoji":"🕞","aliases":["clock330"],"tags":[],"category":"Travel & Places","description":"three-thirty","unicode_version":"6.0"},{"emoji":"🕓","aliases":["clock4"],"tags":[],"category":"Travel & Places","description":"four o’clock","unicode_version":"6.0"},{"emoji":"🕟","aliases":["clock430"],"tags":[],"category":"Travel & Places","description":"four-thirty","unicode_version":"6.0"},{"emoji":"🕔","aliases":["clock5"],"tags":[],"category":"Travel & Places","description":"five o’clock","unicode_version":"6.0"},{"emoji":"🕠","aliases":["clock530"],"tags":[],"category":"Travel & Places","description":"five-thirty","unicode_version":"6.0"},{"emoji":"🕕","aliases":["clock6"],"tags":[],"category":"Travel & Places","description":"six o’clock","unicode_version":"6.0"},{"emoji":"🕡","aliases":["clock630"],"tags":[],"category":"Travel & Places","description":"six-thirty","unicode_version":"6.0"},{"emoji":"🕖","aliases":["clock7"],"tags":[],"category":"Travel & Places","description":"seven o’clock","unicode_version":"6.0"},{"emoji":"🕢","aliases":["clock730"],"tags":[],"category":"Travel & Places","description":"seven-thirty","unicode_version":"6.0"},{"emoji":"🕗","aliases":["clock8"],"tags":[],"category":"Travel & Places","description":"eight o’clock","unicode_version":"6.0"},{"emoji":"🕣","aliases":["clock830"],"tags":[],"category":"Travel & Places","description":"eight-thirty","unicode_version":"6.0"},{"emoji":"🕘","aliases":["clock9"],"tags":[],"category":"Travel & Places","description":"nine o’clock","unicode_version":"6.0"},{"emoji":"🕤","aliases":["clock930"],"tags":[],"category":"Travel & Places","description":"nine-thirty","unicode_version":"6.0"},{"emoji":"🕙","aliases":["clock10"],"tags":[],"category":"Travel & Places","description":"ten o’clock","unicode_version":"6.0"},{"emoji":"🕥","aliases":["clock1030"],"tags":[],"category":"Travel & Places","description":"ten-thirty","unicode_version":"6.0"},{"emoji":"🕚","aliases":["clock11"],"tags":[],"category":"Travel & Places","description":"eleven o’clock","unicode_version":"6.0"},{"emoji":"🕦","aliases":["clock1130"],"tags":[],"category":"Travel & Places","description":"eleven-thirty","unicode_version":"6.0"},{"emoji":"🌑","aliases":["new_moon"],"tags":[],"category":"Travel & Places","description":"new moon","unicode_version":"6.0"},{"emoji":"🌒","aliases":["waxing_crescent_moon"],"tags":[],"category":"Travel & Places","description":"waxing crescent moon","unicode_version":"6.0"},{"emoji":"🌓","aliases":["first_quarter_moon"],"tags":[],"category":"Travel & Places","description":"first quarter moon","unicode_version":"6.0"},{"emoji":"🌔","aliases":["moon","waxing_gibbous_moon"],"tags":[],"category":"Travel & Places","description":"waxing gibbous moon","unicode_version":"6.0"},{"emoji":"🌕","aliases":["full_moon"],"tags":[],"category":"Travel & Places","description":"full moon","unicode_version":"6.0"},{"emoji":"🌖","aliases":["waning_gibbous_moon"],"tags":[],"category":"Travel & Places","description":"waning gibbous moon","unicode_version":"6.0"},{"emoji":"🌗","aliases":["last_quarter_moon"],"tags":[],"category":"Travel & Places","description":"last quarter moon","unicode_version":"6.0"},{"emoji":"🌘","aliases":["waning_crescent_moon"],"tags":[],"category":"Travel & Places","description":"waning crescent moon","unicode_version":"6.0"},{"emoji":"🌙","aliases":["crescent_moon"],"tags":["night"],"category":"Travel & Places","description":"crescent moon","unicode_version":"6.0"},{"emoji":"🌚","aliases":["new_moon_with_face"],"tags":[],"category":"Travel & Places","description":"new moon face","unicode_version":"6.0"},{"emoji":"🌛","aliases":["first_quarter_moon_with_face"],"tags":[],"category":"Travel & Places","description":"first quarter moon face","unicode_version":"6.0"},{"emoji":"🌜","aliases":["last_quarter_moon_with_face"],"tags":[],"category":"Travel & Places","description":"last quarter moon face","unicode_version":"6.0"},{"emoji":"🌡️","aliases":["thermometer"],"tags":[],"category":"Travel & Places","description":"thermometer","unicode_version":"7.0"},{"emoji":"☀️","aliases":["sunny"],"tags":["weather"],"category":"Travel & Places","description":"sun","unicode_version":""},{"emoji":"🌝","aliases":["full_moon_with_face"],"tags":[],"category":"Travel & Places","description":"full moon face","unicode_version":"6.0"},{"emoji":"🌞","aliases":["sun_with_face"],"tags":["summer"],"category":"Travel & Places","description":"sun with face","unicode_version":"6.0"},{"emoji":"🪐","aliases":["ringed_planet"],"tags":[],"category":"Travel & Places","description":"ringed planet","unicode_version":"12.0"},{"emoji":"⭐","aliases":["star"],"tags":[],"category":"Travel & Places","description":"star","unicode_version":"5.1"},{"emoji":"🌟","aliases":["star2"],"tags":[],"category":"Travel & Places","description":"glowing star","unicode_version":"6.0"},{"emoji":"🌠","aliases":["stars"],"tags":[],"category":"Travel & Places","description":"shooting star","unicode_version":"6.0"},{"emoji":"🌌","aliases":["milky_way"],"tags":[],"category":"Travel & Places","description":"milky way","unicode_version":"6.0"},{"emoji":"☁️","aliases":["cloud"],"tags":[],"category":"Travel & Places","description":"cloud","unicode_version":""},{"emoji":"⛅","aliases":["partly_sunny"],"tags":["weather","cloud"],"category":"Travel & Places","description":"sun behind cloud","unicode_version":"5.2"},{"emoji":"⛈️","aliases":["cloud_with_lightning_and_rain"],"tags":[],"category":"Travel & Places","description":"cloud with lightning and rain","unicode_version":"5.2"},{"emoji":"🌤️","aliases":["sun_behind_small_cloud"],"tags":[],"category":"Travel & Places","description":"sun behind small cloud","unicode_version":"7.0"},{"emoji":"🌥️","aliases":["sun_behind_large_cloud"],"tags":[],"category":"Travel & Places","description":"sun behind large cloud","unicode_version":"7.0"},{"emoji":"🌦️","aliases":["sun_behind_rain_cloud"],"tags":[],"category":"Travel & Places","description":"sun behind rain cloud","unicode_version":"7.0"},{"emoji":"🌧️","aliases":["cloud_with_rain"],"tags":[],"category":"Travel & Places","description":"cloud with rain","unicode_version":"7.0"},{"emoji":"🌨️","aliases":["cloud_with_snow"],"tags":[],"category":"Travel & Places","description":"cloud with snow","unicode_version":"7.0"},{"emoji":"🌩️","aliases":["cloud_with_lightning"],"tags":[],"category":"Travel & Places","description":"cloud with lightning","unicode_version":"7.0"},{"emoji":"🌪️","aliases":["tornado"],"tags":[],"category":"Travel & Places","description":"tornado","unicode_version":"7.0"},{"emoji":"🌫️","aliases":["fog"],"tags":[],"category":"Travel & Places","description":"fog","unicode_version":"7.0"},{"emoji":"🌬️","aliases":["wind_face"],"tags":[],"category":"Travel & Places","description":"wind face","unicode_version":"7.0"},{"emoji":"🌀","aliases":["cyclone"],"tags":["swirl"],"category":"Travel & Places","description":"cyclone","unicode_version":"6.0"},{"emoji":"🌈","aliases":["rainbow"],"tags":[],"category":"Travel & Places","description":"rainbow","unicode_version":"6.0"},{"emoji":"🌂","aliases":["closed_umbrella"],"tags":["weather","rain"],"category":"Travel & Places","description":"closed umbrella","unicode_version":"6.0"},{"emoji":"☂️","aliases":["open_umbrella"],"tags":[],"category":"Travel & Places","description":"umbrella","unicode_version":""},{"emoji":"☔","aliases":["umbrella"],"tags":["rain","weather"],"category":"Travel & Places","description":"umbrella with rain drops","unicode_version":"4.0"},{"emoji":"⛱️","aliases":["parasol_on_ground"],"tags":["beach_umbrella"],"category":"Travel & Places","description":"umbrella on ground","unicode_version":"5.2"},{"emoji":"⚡","aliases":["zap"],"tags":["lightning","thunder"],"category":"Travel & Places","description":"high voltage","unicode_version":"4.0"},{"emoji":"❄️","aliases":["snowflake"],"tags":["winter","cold","weather"],"category":"Travel & Places","description":"snowflake","unicode_version":""},{"emoji":"☃️","aliases":["snowman_with_snow"],"tags":["winter","christmas"],"category":"Travel & Places","description":"snowman","unicode_version":""},{"emoji":"⛄","aliases":["snowman"],"tags":["winter"],"category":"Travel & Places","description":"snowman without snow","unicode_version":"5.2"},{"emoji":"☄️","aliases":["comet"],"tags":[],"category":"Travel & Places","description":"comet","unicode_version":""},{"emoji":"🔥","aliases":["fire"],"tags":["burn"],"category":"Travel & Places","description":"fire","unicode_version":"6.0"},{"emoji":"💧","aliases":["droplet"],"tags":["water"],"category":"Travel & Places","description":"droplet","unicode_version":"6.0"},{"emoji":"🌊","aliases":["ocean"],"tags":["sea"],"category":"Travel & Places","description":"water wave","unicode_version":"6.0"},{"emoji":"🎃","aliases":["jack_o_lantern"],"tags":["halloween"],"category":"Activities","description":"jack-o-lantern","unicode_version":"6.0"},{"emoji":"🎄","aliases":["christmas_tree"],"tags":[],"category":"Activities","description":"Christmas tree","unicode_version":"6.0"},{"emoji":"🎆","aliases":["fireworks"],"tags":["festival","celebration"],"category":"Activities","description":"fireworks","unicode_version":"6.0"},{"emoji":"🎇","aliases":["sparkler"],"tags":[],"category":"Activities","description":"sparkler","unicode_version":"6.0"},{"emoji":"🧨","aliases":["firecracker"],"tags":[],"category":"Activities","description":"firecracker","unicode_version":"11.0"},{"emoji":"✨","aliases":["sparkles"],"tags":["shiny"],"category":"Activities","description":"sparkles","unicode_version":"6.0"},{"emoji":"🎈","aliases":["balloon"],"tags":["party","birthday"],"category":"Activities","description":"balloon","unicode_version":"6.0"},{"emoji":"🎉","aliases":["tada"],"tags":["hooray","party"],"category":"Activities","description":"party popper","unicode_version":"6.0"},{"emoji":"🎊","aliases":["confetti_ball"],"tags":[],"category":"Activities","description":"confetti ball","unicode_version":"6.0"},{"emoji":"🎋","aliases":["tanabata_tree"],"tags":[],"category":"Activities","description":"tanabata tree","unicode_version":"6.0"},{"emoji":"🎍","aliases":["bamboo"],"tags":[],"category":"Activities","description":"pine decoration","unicode_version":"6.0"},{"emoji":"🎎","aliases":["dolls"],"tags":[],"category":"Activities","description":"Japanese dolls","unicode_version":"6.0"},{"emoji":"🎏","aliases":["flags"],"tags":[],"category":"Activities","description":"carp streamer","unicode_version":"6.0"},{"emoji":"🎐","aliases":["wind_chime"],"tags":[],"category":"Activities","description":"wind chime","unicode_version":"6.0"},{"emoji":"🎑","aliases":["rice_scene"],"tags":[],"category":"Activities","description":"moon viewing ceremony","unicode_version":"6.0"},{"emoji":"🧧","aliases":["red_envelope"],"tags":[],"category":"Activities","description":"red envelope","unicode_version":"11.0"},{"emoji":"🎀","aliases":["ribbon"],"tags":[],"category":"Activities","description":"ribbon","unicode_version":"6.0"},{"emoji":"🎁","aliases":["gift"],"tags":["present","birthday","christmas"],"category":"Activities","description":"wrapped gift","unicode_version":"6.0"},{"emoji":"🎗️","aliases":["reminder_ribbon"],"tags":[],"category":"Activities","description":"reminder ribbon","unicode_version":"7.0"},{"emoji":"🎟️","aliases":["tickets"],"tags":[],"category":"Activities","description":"admission tickets","unicode_version":"7.0"},{"emoji":"🎫","aliases":["ticket"],"tags":[],"category":"Activities","description":"ticket","unicode_version":"6.0"},{"emoji":"🎖️","aliases":["medal_military"],"tags":[],"category":"Activities","description":"military medal","unicode_version":"7.0"},{"emoji":"🏆","aliases":["trophy"],"tags":["award","contest","winner"],"category":"Activities","description":"trophy","unicode_version":"6.0"},{"emoji":"🏅","aliases":["medal_sports"],"tags":["gold","winner"],"category":"Activities","description":"sports medal","unicode_version":"7.0"},{"emoji":"🥇","aliases":["1st_place_medal"],"tags":["gold"],"category":"Activities","description":"1st place medal","unicode_version":"9.0"},{"emoji":"🥈","aliases":["2nd_place_medal"],"tags":["silver"],"category":"Activities","description":"2nd place medal","unicode_version":"9.0"},{"emoji":"🥉","aliases":["3rd_place_medal"],"tags":["bronze"],"category":"Activities","description":"3rd place medal","unicode_version":"9.0"},{"emoji":"⚽","aliases":["soccer"],"tags":["sports"],"category":"Activities","description":"soccer ball","unicode_version":"5.2"},{"emoji":"⚾","aliases":["baseball"],"tags":["sports"],"category":"Activities","description":"baseball","unicode_version":"5.2"},{"emoji":"🥎","aliases":["softball"],"tags":[],"category":"Activities","description":"softball","unicode_version":"11.0"},{"emoji":"🏀","aliases":["basketball"],"tags":["sports"],"category":"Activities","description":"basketball","unicode_version":"6.0"},{"emoji":"🏐","aliases":["volleyball"],"tags":[],"category":"Activities","description":"volleyball","unicode_version":"8.0"},{"emoji":"🏈","aliases":["football"],"tags":["sports"],"category":"Activities","description":"american football","unicode_version":"6.0"},{"emoji":"🏉","aliases":["rugby_football"],"tags":[],"category":"Activities","description":"rugby football","unicode_version":"6.0"},{"emoji":"🎾","aliases":["tennis"],"tags":["sports"],"category":"Activities","description":"tennis","unicode_version":"6.0"},{"emoji":"🥏","aliases":["flying_disc"],"tags":[],"category":"Activities","description":"flying disc","unicode_version":"11.0"},{"emoji":"🎳","aliases":["bowling"],"tags":[],"category":"Activities","description":"bowling","unicode_version":"6.0"},{"emoji":"🏏","aliases":["cricket_game"],"tags":[],"category":"Activities","description":"cricket game","unicode_version":"8.0"},{"emoji":"🏑","aliases":["field_hockey"],"tags":[],"category":"Activities","description":"field hockey","unicode_version":"8.0"},{"emoji":"🏒","aliases":["ice_hockey"],"tags":[],"category":"Activities","description":"ice hockey","unicode_version":"8.0"},{"emoji":"🥍","aliases":["lacrosse"],"tags":[],"category":"Activities","description":"lacrosse","unicode_version":"11.0"},{"emoji":"🏓","aliases":["ping_pong"],"tags":[],"category":"Activities","description":"ping pong","unicode_version":"8.0"},{"emoji":"🏸","aliases":["badminton"],"tags":[],"category":"Activities","description":"badminton","unicode_version":"8.0"},{"emoji":"🥊","aliases":["boxing_glove"],"tags":[],"category":"Activities","description":"boxing glove","unicode_version":"9.0"},{"emoji":"🥋","aliases":["martial_arts_uniform"],"tags":[],"category":"Activities","description":"martial arts uniform","unicode_version":"9.0"},{"emoji":"🥅","aliases":["goal_net"],"tags":[],"category":"Activities","description":"goal net","unicode_version":"9.0"},{"emoji":"⛳","aliases":["golf"],"tags":[],"category":"Activities","description":"flag in hole","unicode_version":"5.2"},{"emoji":"⛸️","aliases":["ice_skate"],"tags":["skating"],"category":"Activities","description":"ice skate","unicode_version":"5.2"},{"emoji":"🎣","aliases":["fishing_pole_and_fish"],"tags":[],"category":"Activities","description":"fishing pole","unicode_version":"6.0"},{"emoji":"🤿","aliases":["diving_mask"],"tags":[],"category":"Activities","description":"diving mask","unicode_version":"12.0"},{"emoji":"🎽","aliases":["running_shirt_with_sash"],"tags":["marathon"],"category":"Activities","description":"running shirt","unicode_version":"6.0"},{"emoji":"🎿","aliases":["ski"],"tags":[],"category":"Activities","description":"skis","unicode_version":"6.0"},{"emoji":"🛷","aliases":["sled"],"tags":[],"category":"Activities","description":"sled","unicode_version":"11.0"},{"emoji":"🥌","aliases":["curling_stone"],"tags":[],"category":"Activities","description":"curling stone","unicode_version":"11.0"},{"emoji":"🎯","aliases":["dart"],"tags":["target"],"category":"Activities","description":"bullseye","unicode_version":"6.0"},{"emoji":"🪀","aliases":["yo_yo"],"tags":[],"category":"Activities","description":"yo-yo","unicode_version":"12.0"},{"emoji":"🪁","aliases":["kite"],"tags":[],"category":"Activities","description":"kite","unicode_version":"12.0"},{"emoji":"🎱","aliases":["8ball"],"tags":["pool","billiards"],"category":"Activities","description":"pool 8 ball","unicode_version":"6.0"},{"emoji":"🔮","aliases":["crystal_ball"],"tags":["fortune"],"category":"Activities","description":"crystal ball","unicode_version":"6.0"},{"emoji":"🪄","aliases":["magic_wand"],"tags":[],"category":"Activities","description":"magic wand","unicode_version":"13.0"},{"emoji":"🧿","aliases":["nazar_amulet"],"tags":[],"category":"Activities","description":"nazar amulet","unicode_version":"11.0"},{"emoji":"🎮","aliases":["video_game"],"tags":["play","controller","console"],"category":"Activities","description":"video game","unicode_version":"6.0"},{"emoji":"🕹️","aliases":["joystick"],"tags":[],"category":"Activities","description":"joystick","unicode_version":"7.0"},{"emoji":"🎰","aliases":["slot_machine"],"tags":[],"category":"Activities","description":"slot machine","unicode_version":"6.0"},{"emoji":"🎲","aliases":["game_die"],"tags":["dice","gambling"],"category":"Activities","description":"game die","unicode_version":"6.0"},{"emoji":"🧩","aliases":["jigsaw"],"tags":[],"category":"Activities","description":"puzzle piece","unicode_version":"11.0"},{"emoji":"🧸","aliases":["teddy_bear"],"tags":[],"category":"Activities","description":"teddy bear","unicode_version":"11.0"},{"emoji":"🪅","aliases":["pinata"],"tags":[],"category":"Activities","description":"piñata","unicode_version":"13.0"},{"emoji":"🪆","aliases":["nesting_dolls"],"tags":[],"category":"Activities","description":"nesting dolls","unicode_version":"13.0"},{"emoji":"♠️","aliases":["spades"],"tags":[],"category":"Activities","description":"spade suit","unicode_version":""},{"emoji":"♥️","aliases":["hearts"],"tags":[],"category":"Activities","description":"heart suit","unicode_version":""},{"emoji":"♦️","aliases":["diamonds"],"tags":[],"category":"Activities","description":"diamond suit","unicode_version":""},{"emoji":"♣️","aliases":["clubs"],"tags":[],"category":"Activities","description":"club suit","unicode_version":""},{"emoji":"♟️","aliases":["chess_pawn"],"tags":[],"category":"Activities","description":"chess pawn","unicode_version":"11.0"},{"emoji":"🃏","aliases":["black_joker"],"tags":[],"category":"Activities","description":"joker","unicode_version":"6.0"},{"emoji":"🀄","aliases":["mahjong"],"tags":[],"category":"Activities","description":"mahjong red dragon","unicode_version":""},{"emoji":"🎴","aliases":["flower_playing_cards"],"tags":[],"category":"Activities","description":"flower playing cards","unicode_version":"6.0"},{"emoji":"🎭","aliases":["performing_arts"],"tags":["theater","drama"],"category":"Activities","description":"performing arts","unicode_version":"6.0"},{"emoji":"🖼️","aliases":["framed_picture"],"tags":[],"category":"Activities","description":"framed picture","unicode_version":"7.0"},{"emoji":"🎨","aliases":["art"],"tags":["design","paint"],"category":"Activities","description":"artist palette","unicode_version":"6.0"},{"emoji":"🧵","aliases":["thread"],"tags":[],"category":"Activities","description":"thread","unicode_version":"11.0"},{"emoji":"🪡","aliases":["sewing_needle"],"tags":[],"category":"Activities","description":"sewing needle","unicode_version":"13.0"},{"emoji":"🧶","aliases":["yarn"],"tags":[],"category":"Activities","description":"yarn","unicode_version":"11.0"},{"emoji":"🪢","aliases":["knot"],"tags":[],"category":"Activities","description":"knot","unicode_version":"13.0"},{"emoji":"👓","aliases":["eyeglasses"],"tags":["glasses"],"category":"Objects","description":"glasses","unicode_version":"6.0"},{"emoji":"🕶️","aliases":["dark_sunglasses"],"tags":[],"category":"Objects","description":"sunglasses","unicode_version":"7.0"},{"emoji":"🥽","aliases":["goggles"],"tags":[],"category":"Objects","description":"goggles","unicode_version":"11.0"},{"emoji":"🥼","aliases":["lab_coat"],"tags":[],"category":"Objects","description":"lab coat","unicode_version":"11.0"},{"emoji":"🦺","aliases":["safety_vest"],"tags":[],"category":"Objects","description":"safety vest","unicode_version":"12.0"},{"emoji":"👔","aliases":["necktie"],"tags":["shirt","formal"],"category":"Objects","description":"necktie","unicode_version":"6.0"},{"emoji":"👕","aliases":["shirt","tshirt"],"tags":[],"category":"Objects","description":"t-shirt","unicode_version":"6.0"},{"emoji":"👖","aliases":["jeans"],"tags":["pants"],"category":"Objects","description":"jeans","unicode_version":"6.0"},{"emoji":"🧣","aliases":["scarf"],"tags":[],"category":"Objects","description":"scarf","unicode_version":"11.0"},{"emoji":"🧤","aliases":["gloves"],"tags":[],"category":"Objects","description":"gloves","unicode_version":"11.0"},{"emoji":"🧥","aliases":["coat"],"tags":[],"category":"Objects","description":"coat","unicode_version":"11.0"},{"emoji":"🧦","aliases":["socks"],"tags":[],"category":"Objects","description":"socks","unicode_version":"11.0"},{"emoji":"👗","aliases":["dress"],"tags":[],"category":"Objects","description":"dress","unicode_version":"6.0"},{"emoji":"👘","aliases":["kimono"],"tags":[],"category":"Objects","description":"kimono","unicode_version":"6.0"},{"emoji":"🥻","aliases":["sari"],"tags":[],"category":"Objects","description":"sari","unicode_version":"12.0"},{"emoji":"🩱","aliases":["one_piece_swimsuit"],"tags":[],"category":"Objects","description":"one-piece swimsuit","unicode_version":"12.0"},{"emoji":"🩲","aliases":["swim_brief"],"tags":[],"category":"Objects","description":"briefs","unicode_version":"12.0"},{"emoji":"🩳","aliases":["shorts"],"tags":[],"category":"Objects","description":"shorts","unicode_version":"12.0"},{"emoji":"👙","aliases":["bikini"],"tags":["beach"],"category":"Objects","description":"bikini","unicode_version":"6.0"},{"emoji":"👚","aliases":["womans_clothes"],"tags":[],"category":"Objects","description":"woman’s clothes","unicode_version":"6.0"},{"emoji":"👛","aliases":["purse"],"tags":[],"category":"Objects","description":"purse","unicode_version":"6.0"},{"emoji":"👜","aliases":["handbag"],"tags":["bag"],"category":"Objects","description":"handbag","unicode_version":"6.0"},{"emoji":"👝","aliases":["pouch"],"tags":["bag"],"category":"Objects","description":"clutch bag","unicode_version":"6.0"},{"emoji":"🛍️","aliases":["shopping"],"tags":["bags"],"category":"Objects","description":"shopping bags","unicode_version":"7.0"},{"emoji":"🎒","aliases":["school_satchel"],"tags":[],"category":"Objects","description":"backpack","unicode_version":"6.0"},{"emoji":"🩴","aliases":["thong_sandal"],"tags":[],"category":"Objects","description":"thong sandal","unicode_version":"13.0"},{"emoji":"👞","aliases":["mans_shoe","shoe"],"tags":[],"category":"Objects","description":"man’s shoe","unicode_version":"6.0"},{"emoji":"👟","aliases":["athletic_shoe"],"tags":["sneaker","sport","running"],"category":"Objects","description":"running shoe","unicode_version":"6.0"},{"emoji":"🥾","aliases":["hiking_boot"],"tags":[],"category":"Objects","description":"hiking boot","unicode_version":"11.0"},{"emoji":"🥿","aliases":["flat_shoe"],"tags":[],"category":"Objects","description":"flat shoe","unicode_version":"11.0"},{"emoji":"👠","aliases":["high_heel"],"tags":["shoe"],"category":"Objects","description":"high-heeled shoe","unicode_version":"6.0"},{"emoji":"👡","aliases":["sandal"],"tags":["shoe"],"category":"Objects","description":"woman’s sandal","unicode_version":"6.0"},{"emoji":"🩰","aliases":["ballet_shoes"],"tags":[],"category":"Objects","description":"ballet shoes","unicode_version":"12.0"},{"emoji":"👢","aliases":["boot"],"tags":[],"category":"Objects","description":"woman’s boot","unicode_version":"6.0"},{"emoji":"👑","aliases":["crown"],"tags":["king","queen","royal"],"category":"Objects","description":"crown","unicode_version":"6.0"},{"emoji":"👒","aliases":["womans_hat"],"tags":[],"category":"Objects","description":"woman’s hat","unicode_version":"6.0"},{"emoji":"🎩","aliases":["tophat"],"tags":["hat","classy"],"category":"Objects","description":"top hat","unicode_version":"6.0"},{"emoji":"🎓","aliases":["mortar_board"],"tags":["education","college","university","graduation"],"category":"Objects","description":"graduation cap","unicode_version":"6.0"},{"emoji":"🧢","aliases":["billed_cap"],"tags":[],"category":"Objects","description":"billed cap","unicode_version":"11.0"},{"emoji":"🪖","aliases":["military_helmet"],"tags":[],"category":"Objects","description":"military helmet","unicode_version":"13.0"},{"emoji":"⛑️","aliases":["rescue_worker_helmet"],"tags":[],"category":"Objects","description":"rescue worker’s helmet","unicode_version":"5.2"},{"emoji":"📿","aliases":["prayer_beads"],"tags":[],"category":"Objects","description":"prayer beads","unicode_version":"8.0"},{"emoji":"💄","aliases":["lipstick"],"tags":["makeup"],"category":"Objects","description":"lipstick","unicode_version":"6.0"},{"emoji":"💍","aliases":["ring"],"tags":["wedding","marriage","engaged"],"category":"Objects","description":"ring","unicode_version":"6.0"},{"emoji":"💎","aliases":["gem"],"tags":["diamond"],"category":"Objects","description":"gem stone","unicode_version":"6.0"},{"emoji":"🔇","aliases":["mute"],"tags":["sound","volume"],"category":"Objects","description":"muted speaker","unicode_version":"6.0"},{"emoji":"🔈","aliases":["speaker"],"tags":[],"category":"Objects","description":"speaker low volume","unicode_version":"6.0"},{"emoji":"🔉","aliases":["sound"],"tags":["volume"],"category":"Objects","description":"speaker medium volume","unicode_version":"6.0"},{"emoji":"🔊","aliases":["loud_sound"],"tags":["volume"],"category":"Objects","description":"speaker high volume","unicode_version":"6.0"},{"emoji":"📢","aliases":["loudspeaker"],"tags":["announcement"],"category":"Objects","description":"loudspeaker","unicode_version":"6.0"},{"emoji":"📣","aliases":["mega"],"tags":[],"category":"Objects","description":"megaphone","unicode_version":"6.0"},{"emoji":"📯","aliases":["postal_horn"],"tags":[],"category":"Objects","description":"postal horn","unicode_version":"6.0"},{"emoji":"🔔","aliases":["bell"],"tags":["sound","notification"],"category":"Objects","description":"bell","unicode_version":"6.0"},{"emoji":"🔕","aliases":["no_bell"],"tags":["volume","off"],"category":"Objects","description":"bell with slash","unicode_version":"6.0"},{"emoji":"🎼","aliases":["musical_score"],"tags":[],"category":"Objects","description":"musical score","unicode_version":"6.0"},{"emoji":"🎵","aliases":["musical_note"],"tags":[],"category":"Objects","description":"musical note","unicode_version":"6.0"},{"emoji":"🎶","aliases":["notes"],"tags":["music"],"category":"Objects","description":"musical notes","unicode_version":"6.0"},{"emoji":"🎙️","aliases":["studio_microphone"],"tags":["podcast"],"category":"Objects","description":"studio microphone","unicode_version":"7.0"},{"emoji":"🎚️","aliases":["level_slider"],"tags":[],"category":"Objects","description":"level slider","unicode_version":"7.0"},{"emoji":"🎛️","aliases":["control_knobs"],"tags":[],"category":"Objects","description":"control knobs","unicode_version":"7.0"},{"emoji":"🎤","aliases":["microphone"],"tags":["sing"],"category":"Objects","description":"microphone","unicode_version":"6.0"},{"emoji":"🎧","aliases":["headphones"],"tags":["music","earphones"],"category":"Objects","description":"headphone","unicode_version":"6.0"},{"emoji":"📻","aliases":["radio"],"tags":["podcast"],"category":"Objects","description":"radio","unicode_version":"6.0"},{"emoji":"🎷","aliases":["saxophone"],"tags":[],"category":"Objects","description":"saxophone","unicode_version":"6.0"},{"emoji":"🪗","aliases":["accordion"],"tags":[],"category":"Objects","description":"accordion","unicode_version":"13.0"},{"emoji":"🎸","aliases":["guitar"],"tags":["rock"],"category":"Objects","description":"guitar","unicode_version":"6.0"},{"emoji":"🎹","aliases":["musical_keyboard"],"tags":["piano"],"category":"Objects","description":"musical keyboard","unicode_version":"6.0"},{"emoji":"🎺","aliases":["trumpet"],"tags":[],"category":"Objects","description":"trumpet","unicode_version":"6.0"},{"emoji":"🎻","aliases":["violin"],"tags":[],"category":"Objects","description":"violin","unicode_version":"6.0"},{"emoji":"🪕","aliases":["banjo"],"tags":[],"category":"Objects","description":"banjo","unicode_version":"12.0"},{"emoji":"🥁","aliases":["drum"],"tags":[],"category":"Objects","description":"drum","unicode_version":""},{"emoji":"🪘","aliases":["long_drum"],"tags":[],"category":"Objects","description":"long drum","unicode_version":"13.0"},{"emoji":"📱","aliases":["iphone"],"tags":["smartphone","mobile"],"category":"Objects","description":"mobile phone","unicode_version":"6.0"},{"emoji":"📲","aliases":["calling"],"tags":["call","incoming"],"category":"Objects","description":"mobile phone with arrow","unicode_version":"6.0"},{"emoji":"☎️","aliases":["phone","telephone"],"tags":[],"category":"Objects","description":"telephone","unicode_version":""},{"emoji":"📞","aliases":["telephone_receiver"],"tags":["phone","call"],"category":"Objects","description":"telephone receiver","unicode_version":"6.0"},{"emoji":"📟","aliases":["pager"],"tags":[],"category":"Objects","description":"pager","unicode_version":"6.0"},{"emoji":"📠","aliases":["fax"],"tags":[],"category":"Objects","description":"fax machine","unicode_version":"6.0"},{"emoji":"🔋","aliases":["battery"],"tags":["power"],"category":"Objects","description":"battery","unicode_version":"6.0"},{"emoji":"🔌","aliases":["electric_plug"],"tags":[],"category":"Objects","description":"electric plug","unicode_version":"6.0"},{"emoji":"💻","aliases":["computer"],"tags":["desktop","screen"],"category":"Objects","description":"laptop","unicode_version":"6.0"},{"emoji":"🖥️","aliases":["desktop_computer"],"tags":[],"category":"Objects","description":"desktop computer","unicode_version":"7.0"},{"emoji":"🖨️","aliases":["printer"],"tags":[],"category":"Objects","description":"printer","unicode_version":"7.0"},{"emoji":"⌨️","aliases":["keyboard"],"tags":[],"category":"Objects","description":"keyboard","unicode_version":""},{"emoji":"🖱️","aliases":["computer_mouse"],"tags":[],"category":"Objects","description":"computer mouse","unicode_version":"7.0"},{"emoji":"🖲️","aliases":["trackball"],"tags":[],"category":"Objects","description":"trackball","unicode_version":"7.0"},{"emoji":"💽","aliases":["minidisc"],"tags":[],"category":"Objects","description":"computer disk","unicode_version":"6.0"},{"emoji":"💾","aliases":["floppy_disk"],"tags":["save"],"category":"Objects","description":"floppy disk","unicode_version":"6.0"},{"emoji":"💿","aliases":["cd"],"tags":[],"category":"Objects","description":"optical disk","unicode_version":"6.0"},{"emoji":"📀","aliases":["dvd"],"tags":[],"category":"Objects","description":"dvd","unicode_version":"6.0"},{"emoji":"🧮","aliases":["abacus"],"tags":[],"category":"Objects","description":"abacus","unicode_version":"11.0"},{"emoji":"🎥","aliases":["movie_camera"],"tags":["film","video"],"category":"Objects","description":"movie camera","unicode_version":"6.0"},{"emoji":"🎞️","aliases":["film_strip"],"tags":[],"category":"Objects","description":"film frames","unicode_version":"7.0"},{"emoji":"📽️","aliases":["film_projector"],"tags":[],"category":"Objects","description":"film projector","unicode_version":"7.0"},{"emoji":"🎬","aliases":["clapper"],"tags":["film"],"category":"Objects","description":"clapper board","unicode_version":"6.0"},{"emoji":"📺","aliases":["tv"],"tags":[],"category":"Objects","description":"television","unicode_version":"6.0"},{"emoji":"📷","aliases":["camera"],"tags":["photo"],"category":"Objects","description":"camera","unicode_version":"6.0"},{"emoji":"📸","aliases":["camera_flash"],"tags":["photo"],"category":"Objects","description":"camera with flash","unicode_version":"7.0"},{"emoji":"📹","aliases":["video_camera"],"tags":[],"category":"Objects","description":"video camera","unicode_version":"6.0"},{"emoji":"📼","aliases":["vhs"],"tags":[],"category":"Objects","description":"videocassette","unicode_version":"6.0"},{"emoji":"🔍","aliases":["mag"],"tags":["search","zoom"],"category":"Objects","description":"magnifying glass tilted left","unicode_version":"6.0"},{"emoji":"🔎","aliases":["mag_right"],"tags":[],"category":"Objects","description":"magnifying glass tilted right","unicode_version":"6.0"},{"emoji":"🕯️","aliases":["candle"],"tags":[],"category":"Objects","description":"candle","unicode_version":"7.0"},{"emoji":"💡","aliases":["bulb"],"tags":["idea","light"],"category":"Objects","description":"light bulb","unicode_version":"6.0"},{"emoji":"🔦","aliases":["flashlight"],"tags":[],"category":"Objects","description":"flashlight","unicode_version":"6.0"},{"emoji":"🏮","aliases":["izakaya_lantern","lantern"],"tags":[],"category":"Objects","description":"red paper lantern","unicode_version":"6.0"},{"emoji":"🪔","aliases":["diya_lamp"],"tags":[],"category":"Objects","description":"diya lamp","unicode_version":"12.0"},{"emoji":"📔","aliases":["notebook_with_decorative_cover"],"tags":[],"category":"Objects","description":"notebook with decorative cover","unicode_version":"6.0"},{"emoji":"📕","aliases":["closed_book"],"tags":[],"category":"Objects","description":"closed book","unicode_version":"6.0"},{"emoji":"📖","aliases":["book","open_book"],"tags":[],"category":"Objects","description":"open book","unicode_version":"6.0"},{"emoji":"📗","aliases":["green_book"],"tags":[],"category":"Objects","description":"green book","unicode_version":"6.0"},{"emoji":"📘","aliases":["blue_book"],"tags":[],"category":"Objects","description":"blue book","unicode_version":"6.0"},{"emoji":"📙","aliases":["orange_book"],"tags":[],"category":"Objects","description":"orange book","unicode_version":"6.0"},{"emoji":"📚","aliases":["books"],"tags":["library"],"category":"Objects","description":"books","unicode_version":"6.0"},{"emoji":"📓","aliases":["notebook"],"tags":[],"category":"Objects","description":"notebook","unicode_version":"6.0"},{"emoji":"📒","aliases":["ledger"],"tags":[],"category":"Objects","description":"ledger","unicode_version":"6.0"},{"emoji":"📃","aliases":["page_with_curl"],"tags":[],"category":"Objects","description":"page with curl","unicode_version":"6.0"},{"emoji":"📜","aliases":["scroll"],"tags":["document"],"category":"Objects","description":"scroll","unicode_version":"6.0"},{"emoji":"📄","aliases":["page_facing_up"],"tags":["document"],"category":"Objects","description":"page facing up","unicode_version":"6.0"},{"emoji":"📰","aliases":["newspaper"],"tags":["press"],"category":"Objects","description":"newspaper","unicode_version":"6.0"},{"emoji":"🗞️","aliases":["newspaper_roll"],"tags":["press"],"category":"Objects","description":"rolled-up newspaper","unicode_version":"7.0"},{"emoji":"📑","aliases":["bookmark_tabs"],"tags":[],"category":"Objects","description":"bookmark tabs","unicode_version":"6.0"},{"emoji":"🔖","aliases":["bookmark"],"tags":[],"category":"Objects","description":"bookmark","unicode_version":"6.0"},{"emoji":"🏷️","aliases":["label"],"tags":["tag"],"category":"Objects","description":"label","unicode_version":"7.0"},{"emoji":"💰","aliases":["moneybag"],"tags":["dollar","cream"],"category":"Objects","description":"money bag","unicode_version":"6.0"},{"emoji":"🪙","aliases":["coin"],"tags":[],"category":"Objects","description":"coin","unicode_version":"13.0"},{"emoji":"💴","aliases":["yen"],"tags":[],"category":"Objects","description":"yen banknote","unicode_version":"6.0"},{"emoji":"💵","aliases":["dollar"],"tags":["money"],"category":"Objects","description":"dollar banknote","unicode_version":"6.0"},{"emoji":"💶","aliases":["euro"],"tags":[],"category":"Objects","description":"euro banknote","unicode_version":"6.0"},{"emoji":"💷","aliases":["pound"],"tags":[],"category":"Objects","description":"pound banknote","unicode_version":"6.0"},{"emoji":"💸","aliases":["money_with_wings"],"tags":["dollar"],"category":"Objects","description":"money with wings","unicode_version":"6.0"},{"emoji":"💳","aliases":["credit_card"],"tags":["subscription"],"category":"Objects","description":"credit card","unicode_version":"6.0"},{"emoji":"🧾","aliases":["receipt"],"tags":[],"category":"Objects","description":"receipt","unicode_version":"11.0"},{"emoji":"💹","aliases":["chart"],"tags":[],"category":"Objects","description":"chart increasing with yen","unicode_version":"6.0"},{"emoji":"✉️","aliases":["envelope"],"tags":["letter","email"],"category":"Objects","description":"envelope","unicode_version":""},{"emoji":"📧","aliases":["email","e-mail"],"tags":[],"category":"Objects","description":"e-mail","unicode_version":"6.0"},{"emoji":"📨","aliases":["incoming_envelope"],"tags":[],"category":"Objects","description":"incoming envelope","unicode_version":"6.0"},{"emoji":"📩","aliases":["envelope_with_arrow"],"tags":[],"category":"Objects","description":"envelope with arrow","unicode_version":"6.0"},{"emoji":"📤","aliases":["outbox_tray"],"tags":[],"category":"Objects","description":"outbox tray","unicode_version":"6.0"},{"emoji":"📥","aliases":["inbox_tray"],"tags":[],"category":"Objects","description":"inbox tray","unicode_version":"6.0"},{"emoji":"📦","aliases":["package"],"tags":["shipping"],"category":"Objects","description":"package","unicode_version":"6.0"},{"emoji":"📫","aliases":["mailbox"],"tags":[],"category":"Objects","description":"closed mailbox with raised flag","unicode_version":"6.0"},{"emoji":"📪","aliases":["mailbox_closed"],"tags":[],"category":"Objects","description":"closed mailbox with lowered flag","unicode_version":"6.0"},{"emoji":"📬","aliases":["mailbox_with_mail"],"tags":[],"category":"Objects","description":"open mailbox with raised flag","unicode_version":"6.0"},{"emoji":"📭","aliases":["mailbox_with_no_mail"],"tags":[],"category":"Objects","description":"open mailbox with lowered flag","unicode_version":"6.0"},{"emoji":"📮","aliases":["postbox"],"tags":[],"category":"Objects","description":"postbox","unicode_version":"6.0"},{"emoji":"🗳️","aliases":["ballot_box"],"tags":[],"category":"Objects","description":"ballot box with ballot","unicode_version":"7.0"},{"emoji":"✏️","aliases":["pencil2"],"tags":[],"category":"Objects","description":"pencil","unicode_version":""},{"emoji":"✒️","aliases":["black_nib"],"tags":[],"category":"Objects","description":"black nib","unicode_version":""},{"emoji":"🖋️","aliases":["fountain_pen"],"tags":[],"category":"Objects","description":"fountain pen","unicode_version":"7.0"},{"emoji":"🖊️","aliases":["pen"],"tags":[],"category":"Objects","description":"pen","unicode_version":"7.0"},{"emoji":"🖌️","aliases":["paintbrush"],"tags":[],"category":"Objects","description":"paintbrush","unicode_version":"7.0"},{"emoji":"🖍️","aliases":["crayon"],"tags":[],"category":"Objects","description":"crayon","unicode_version":"7.0"},{"emoji":"📝","aliases":["memo","pencil"],"tags":["document","note"],"category":"Objects","description":"memo","unicode_version":"6.0"},{"emoji":"💼","aliases":["briefcase"],"tags":["business"],"category":"Objects","description":"briefcase","unicode_version":"6.0"},{"emoji":"📁","aliases":["file_folder"],"tags":["directory"],"category":"Objects","description":"file folder","unicode_version":"6.0"},{"emoji":"📂","aliases":["open_file_folder"],"tags":[],"category":"Objects","description":"open file folder","unicode_version":"6.0"},{"emoji":"🗂️","aliases":["card_index_dividers"],"tags":[],"category":"Objects","description":"card index dividers","unicode_version":"7.0"},{"emoji":"📅","aliases":["date"],"tags":["calendar","schedule"],"category":"Objects","description":"calendar","unicode_version":"6.0"},{"emoji":"📆","aliases":["calendar"],"tags":["schedule"],"category":"Objects","description":"tear-off calendar","unicode_version":"6.0"},{"emoji":"🗒️","aliases":["spiral_notepad"],"tags":[],"category":"Objects","description":"spiral notepad","unicode_version":"7.0"},{"emoji":"🗓️","aliases":["spiral_calendar"],"tags":[],"category":"Objects","description":"spiral calendar","unicode_version":"7.0"},{"emoji":"📇","aliases":["card_index"],"tags":[],"category":"Objects","description":"card index","unicode_version":"6.0"},{"emoji":"📈","aliases":["chart_with_upwards_trend"],"tags":["graph","metrics"],"category":"Objects","description":"chart increasing","unicode_version":"6.0"},{"emoji":"📉","aliases":["chart_with_downwards_trend"],"tags":["graph","metrics"],"category":"Objects","description":"chart decreasing","unicode_version":"6.0"},{"emoji":"📊","aliases":["bar_chart"],"tags":["stats","metrics"],"category":"Objects","description":"bar chart","unicode_version":"6.0"},{"emoji":"📋","aliases":["clipboard"],"tags":[],"category":"Objects","description":"clipboard","unicode_version":"6.0"},{"emoji":"📌","aliases":["pushpin"],"tags":["location"],"category":"Objects","description":"pushpin","unicode_version":"6.0"},{"emoji":"📍","aliases":["round_pushpin"],"tags":["location"],"category":"Objects","description":"round pushpin","unicode_version":"6.0"},{"emoji":"📎","aliases":["paperclip"],"tags":[],"category":"Objects","description":"paperclip","unicode_version":"6.0"},{"emoji":"🖇️","aliases":["paperclips"],"tags":[],"category":"Objects","description":"linked paperclips","unicode_version":"7.0"},{"emoji":"📏","aliases":["straight_ruler"],"tags":[],"category":"Objects","description":"straight ruler","unicode_version":"6.0"},{"emoji":"📐","aliases":["triangular_ruler"],"tags":[],"category":"Objects","description":"triangular ruler","unicode_version":"6.0"},{"emoji":"✂️","aliases":["scissors"],"tags":["cut"],"category":"Objects","description":"scissors","unicode_version":""},{"emoji":"🗃️","aliases":["card_file_box"],"tags":[],"category":"Objects","description":"card file box","unicode_version":"7.0"},{"emoji":"🗄️","aliases":["file_cabinet"],"tags":[],"category":"Objects","description":"file cabinet","unicode_version":"7.0"},{"emoji":"🗑️","aliases":["wastebasket"],"tags":["trash"],"category":"Objects","description":"wastebasket","unicode_version":"7.0"},{"emoji":"🔒","aliases":["lock"],"tags":["security","private"],"category":"Objects","description":"locked","unicode_version":"6.0"},{"emoji":"🔓","aliases":["unlock"],"tags":["security"],"category":"Objects","description":"unlocked","unicode_version":"6.0"},{"emoji":"🔏","aliases":["lock_with_ink_pen"],"tags":[],"category":"Objects","description":"locked with pen","unicode_version":"6.0"},{"emoji":"🔐","aliases":["closed_lock_with_key"],"tags":["security"],"category":"Objects","description":"locked with key","unicode_version":"6.0"},{"emoji":"🔑","aliases":["key"],"tags":["lock","password"],"category":"Objects","description":"key","unicode_version":"6.0"},{"emoji":"🗝️","aliases":["old_key"],"tags":[],"category":"Objects","description":"old key","unicode_version":"7.0"},{"emoji":"🔨","aliases":["hammer"],"tags":["tool"],"category":"Objects","description":"hammer","unicode_version":"6.0"},{"emoji":"🪓","aliases":["axe"],"tags":[],"category":"Objects","description":"axe","unicode_version":"12.0"},{"emoji":"⛏️","aliases":["pick"],"tags":[],"category":"Objects","description":"pick","unicode_version":"5.2"},{"emoji":"⚒️","aliases":["hammer_and_pick"],"tags":[],"category":"Objects","description":"hammer and pick","unicode_version":"4.1"},{"emoji":"🛠️","aliases":["hammer_and_wrench"],"tags":[],"category":"Objects","description":"hammer and wrench","unicode_version":"7.0"},{"emoji":"🗡️","aliases":["dagger"],"tags":[],"category":"Objects","description":"dagger","unicode_version":"7.0"},{"emoji":"⚔️","aliases":["crossed_swords"],"tags":[],"category":"Objects","description":"crossed swords","unicode_version":"4.1"},{"emoji":"🔫","aliases":["gun"],"tags":["shoot","weapon"],"category":"Objects","description":"water pistol","unicode_version":"6.0"},{"emoji":"🪃","aliases":["boomerang"],"tags":[],"category":"Objects","description":"boomerang","unicode_version":"13.0"},{"emoji":"🏹","aliases":["bow_and_arrow"],"tags":["archery"],"category":"Objects","description":"bow and arrow","unicode_version":"8.0"},{"emoji":"🛡️","aliases":["shield"],"tags":[],"category":"Objects","description":"shield","unicode_version":"7.0"},{"emoji":"🪚","aliases":["carpentry_saw"],"tags":[],"category":"Objects","description":"carpentry saw","unicode_version":"13.0"},{"emoji":"🔧","aliases":["wrench"],"tags":["tool"],"category":"Objects","description":"wrench","unicode_version":"6.0"},{"emoji":"🪛","aliases":["screwdriver"],"tags":[],"category":"Objects","description":"screwdriver","unicode_version":"13.0"},{"emoji":"🔩","aliases":["nut_and_bolt"],"tags":[],"category":"Objects","description":"nut and bolt","unicode_version":"6.0"},{"emoji":"⚙️","aliases":["gear"],"tags":[],"category":"Objects","description":"gear","unicode_version":"4.1"},{"emoji":"🗜️","aliases":["clamp"],"tags":[],"category":"Objects","description":"clamp","unicode_version":"7.0"},{"emoji":"⚖️","aliases":["balance_scale"],"tags":[],"category":"Objects","description":"balance scale","unicode_version":"4.1"},{"emoji":"🦯","aliases":["probing_cane"],"tags":[],"category":"Objects","description":"white cane","unicode_version":"12.0"},{"emoji":"🔗","aliases":["link"],"tags":[],"category":"Objects","description":"link","unicode_version":"6.0"},{"emoji":"⛓️","aliases":["chains"],"tags":[],"category":"Objects","description":"chains","unicode_version":"5.2"},{"emoji":"🪝","aliases":["hook"],"tags":[],"category":"Objects","description":"hook","unicode_version":"13.0"},{"emoji":"🧰","aliases":["toolbox"],"tags":[],"category":"Objects","description":"toolbox","unicode_version":"11.0"},{"emoji":"🧲","aliases":["magnet"],"tags":[],"category":"Objects","description":"magnet","unicode_version":"11.0"},{"emoji":"🪜","aliases":["ladder"],"tags":[],"category":"Objects","description":"ladder","unicode_version":"13.0"},{"emoji":"⚗️","aliases":["alembic"],"tags":[],"category":"Objects","description":"alembic","unicode_version":"4.1"},{"emoji":"🧪","aliases":["test_tube"],"tags":[],"category":"Objects","description":"test tube","unicode_version":"11.0"},{"emoji":"🧫","aliases":["petri_dish"],"tags":[],"category":"Objects","description":"petri dish","unicode_version":"11.0"},{"emoji":"🧬","aliases":["dna"],"tags":[],"category":"Objects","description":"dna","unicode_version":"11.0"},{"emoji":"🔬","aliases":["microscope"],"tags":["science","laboratory","investigate"],"category":"Objects","description":"microscope","unicode_version":"6.0"},{"emoji":"🔭","aliases":["telescope"],"tags":[],"category":"Objects","description":"telescope","unicode_version":"6.0"},{"emoji":"📡","aliases":["satellite"],"tags":["signal"],"category":"Objects","description":"satellite antenna","unicode_version":"6.0"},{"emoji":"💉","aliases":["syringe"],"tags":["health","hospital","needle"],"category":"Objects","description":"syringe","unicode_version":"6.0"},{"emoji":"🩸","aliases":["drop_of_blood"],"tags":[],"category":"Objects","description":"drop of blood","unicode_version":"12.0"},{"emoji":"💊","aliases":["pill"],"tags":["health","medicine"],"category":"Objects","description":"pill","unicode_version":"6.0"},{"emoji":"🩹","aliases":["adhesive_bandage"],"tags":[],"category":"Objects","description":"adhesive bandage","unicode_version":"12.0"},{"emoji":"🩺","aliases":["stethoscope"],"tags":[],"category":"Objects","description":"stethoscope","unicode_version":"12.0"},{"emoji":"🚪","aliases":["door"],"tags":[],"category":"Objects","description":"door","unicode_version":"6.0"},{"emoji":"🛗","aliases":["elevator"],"tags":[],"category":"Objects","description":"elevator","unicode_version":"13.0"},{"emoji":"🪞","aliases":["mirror"],"tags":[],"category":"Objects","description":"mirror","unicode_version":"13.0"},{"emoji":"🪟","aliases":["window"],"tags":[],"category":"Objects","description":"window","unicode_version":"13.0"},{"emoji":"🛏️","aliases":["bed"],"tags":[],"category":"Objects","description":"bed","unicode_version":"7.0"},{"emoji":"🛋️","aliases":["couch_and_lamp"],"tags":[],"category":"Objects","description":"couch and lamp","unicode_version":"7.0"},{"emoji":"🪑","aliases":["chair"],"tags":[],"category":"Objects","description":"chair","unicode_version":"12.0"},{"emoji":"🚽","aliases":["toilet"],"tags":["wc"],"category":"Objects","description":"toilet","unicode_version":"6.0"},{"emoji":"🪠","aliases":["plunger"],"tags":[],"category":"Objects","description":"plunger","unicode_version":"13.0"},{"emoji":"🚿","aliases":["shower"],"tags":["bath"],"category":"Objects","description":"shower","unicode_version":"6.0"},{"emoji":"🛁","aliases":["bathtub"],"tags":[],"category":"Objects","description":"bathtub","unicode_version":"6.0"},{"emoji":"🪤","aliases":["mouse_trap"],"tags":[],"category":"Objects","description":"mouse trap","unicode_version":"13.0"},{"emoji":"🪒","aliases":["razor"],"tags":[],"category":"Objects","description":"razor","unicode_version":"12.0"},{"emoji":"🧴","aliases":["lotion_bottle"],"tags":[],"category":"Objects","description":"lotion bottle","unicode_version":"11.0"},{"emoji":"🧷","aliases":["safety_pin"],"tags":[],"category":"Objects","description":"safety pin","unicode_version":"11.0"},{"emoji":"🧹","aliases":["broom"],"tags":[],"category":"Objects","description":"broom","unicode_version":"11.0"},{"emoji":"🧺","aliases":["basket"],"tags":[],"category":"Objects","description":"basket","unicode_version":"11.0"},{"emoji":"🧻","aliases":["roll_of_paper"],"tags":["toilet"],"category":"Objects","description":"roll of paper","unicode_version":"11.0"},{"emoji":"🪣","aliases":["bucket"],"tags":[],"category":"Objects","description":"bucket","unicode_version":"13.0"},{"emoji":"🧼","aliases":["soap"],"tags":[],"category":"Objects","description":"soap","unicode_version":"11.0"},{"emoji":"🪥","aliases":["toothbrush"],"tags":[],"category":"Objects","description":"toothbrush","unicode_version":"13.0"},{"emoji":"🧽","aliases":["sponge"],"tags":[],"category":"Objects","description":"sponge","unicode_version":"11.0"},{"emoji":"🧯","aliases":["fire_extinguisher"],"tags":[],"category":"Objects","description":"fire extinguisher","unicode_version":"11.0"},{"emoji":"🛒","aliases":["shopping_cart"],"tags":[],"category":"Objects","description":"shopping cart","unicode_version":"9.0"},{"emoji":"🚬","aliases":["smoking"],"tags":["cigarette"],"category":"Objects","description":"cigarette","unicode_version":"6.0"},{"emoji":"⚰️","aliases":["coffin"],"tags":["funeral"],"category":"Objects","description":"coffin","unicode_version":"4.1"},{"emoji":"🪦","aliases":["headstone"],"tags":[],"category":"Objects","description":"headstone","unicode_version":"13.0"},{"emoji":"⚱️","aliases":["funeral_urn"],"tags":[],"category":"Objects","description":"funeral urn","unicode_version":"4.1"},{"emoji":"🗿","aliases":["moyai"],"tags":["stone"],"category":"Objects","description":"moai","unicode_version":"6.0"},{"emoji":"🪧","aliases":["placard"],"tags":[],"category":"Objects","description":"placard","unicode_version":"13.0"},{"emoji":"🏧","aliases":["atm"],"tags":[],"category":"Symbols","description":"ATM sign","unicode_version":"6.0"},{"emoji":"🚮","aliases":["put_litter_in_its_place"],"tags":[],"category":"Symbols","description":"litter in bin sign","unicode_version":"6.0"},{"emoji":"🚰","aliases":["potable_water"],"tags":[],"category":"Symbols","description":"potable water","unicode_version":"6.0"},{"emoji":"♿","aliases":["wheelchair"],"tags":["accessibility"],"category":"Symbols","description":"wheelchair symbol","unicode_version":"4.1"},{"emoji":"🚹","aliases":["mens"],"tags":[],"category":"Symbols","description":"men’s room","unicode_version":"6.0"},{"emoji":"🚺","aliases":["womens"],"tags":[],"category":"Symbols","description":"women’s room","unicode_version":"6.0"},{"emoji":"🚻","aliases":["restroom"],"tags":["toilet"],"category":"Symbols","description":"restroom","unicode_version":"6.0"},{"emoji":"🚼","aliases":["baby_symbol"],"tags":[],"category":"Symbols","description":"baby symbol","unicode_version":"6.0"},{"emoji":"🚾","aliases":["wc"],"tags":["toilet","restroom"],"category":"Symbols","description":"water closet","unicode_version":"6.0"},{"emoji":"🛂","aliases":["passport_control"],"tags":[],"category":"Symbols","description":"passport control","unicode_version":"6.0"},{"emoji":"🛃","aliases":["customs"],"tags":[],"category":"Symbols","description":"customs","unicode_version":"6.0"},{"emoji":"🛄","aliases":["baggage_claim"],"tags":["airport"],"category":"Symbols","description":"baggage claim","unicode_version":"6.0"},{"emoji":"🛅","aliases":["left_luggage"],"tags":[],"category":"Symbols","description":"left luggage","unicode_version":"6.0"},{"emoji":"⚠️","aliases":["warning"],"tags":["wip"],"category":"Symbols","description":"warning","unicode_version":"4.0"},{"emoji":"🚸","aliases":["children_crossing"],"tags":[],"category":"Symbols","description":"children crossing","unicode_version":"6.0"},{"emoji":"⛔","aliases":["no_entry"],"tags":["limit"],"category":"Symbols","description":"no entry","unicode_version":"5.2"},{"emoji":"🚫","aliases":["no_entry_sign"],"tags":["block","forbidden"],"category":"Symbols","description":"prohibited","unicode_version":"6.0"},{"emoji":"🚳","aliases":["no_bicycles"],"tags":[],"category":"Symbols","description":"no bicycles","unicode_version":"6.0"},{"emoji":"🚭","aliases":["no_smoking"],"tags":[],"category":"Symbols","description":"no smoking","unicode_version":"6.0"},{"emoji":"🚯","aliases":["do_not_litter"],"tags":[],"category":"Symbols","description":"no littering","unicode_version":"6.0"},{"emoji":"🚱","aliases":["non-potable_water"],"tags":[],"category":"Symbols","description":"non-potable water","unicode_version":"6.0"},{"emoji":"🚷","aliases":["no_pedestrians"],"tags":[],"category":"Symbols","description":"no pedestrians","unicode_version":"6.0"},{"emoji":"📵","aliases":["no_mobile_phones"],"tags":[],"category":"Symbols","description":"no mobile phones","unicode_version":"6.0"},{"emoji":"🔞","aliases":["underage"],"tags":[],"category":"Symbols","description":"no one under eighteen","unicode_version":"6.0"},{"emoji":"☢️","aliases":["radioactive"],"tags":[],"category":"Symbols","description":"radioactive","unicode_version":""},{"emoji":"☣️","aliases":["biohazard"],"tags":[],"category":"Symbols","description":"biohazard","unicode_version":""},{"emoji":"⬆️","aliases":["arrow_up"],"tags":[],"category":"Symbols","description":"up arrow","unicode_version":"4.0"},{"emoji":"↗️","aliases":["arrow_upper_right"],"tags":[],"category":"Symbols","description":"up-right arrow","unicode_version":""},{"emoji":"➡️","aliases":["arrow_right"],"tags":[],"category":"Symbols","description":"right arrow","unicode_version":""},{"emoji":"↘️","aliases":["arrow_lower_right"],"tags":[],"category":"Symbols","description":"down-right arrow","unicode_version":""},{"emoji":"⬇️","aliases":["arrow_down"],"tags":[],"category":"Symbols","description":"down arrow","unicode_version":"4.0"},{"emoji":"↙️","aliases":["arrow_lower_left"],"tags":[],"category":"Symbols","description":"down-left arrow","unicode_version":""},{"emoji":"⬅️","aliases":["arrow_left"],"tags":[],"category":"Symbols","description":"left arrow","unicode_version":"4.0"},{"emoji":"↖️","aliases":["arrow_upper_left"],"tags":[],"category":"Symbols","description":"up-left arrow","unicode_version":""},{"emoji":"↕️","aliases":["arrow_up_down"],"tags":[],"category":"Symbols","description":"up-down arrow","unicode_version":""},{"emoji":"↔️","aliases":["left_right_arrow"],"tags":[],"category":"Symbols","description":"left-right arrow","unicode_version":""},{"emoji":"↩️","aliases":["leftwards_arrow_with_hook"],"tags":["return"],"category":"Symbols","description":"right arrow curving left","unicode_version":""},{"emoji":"↪️","aliases":["arrow_right_hook"],"tags":[],"category":"Symbols","description":"left arrow curving right","unicode_version":""},{"emoji":"⤴️","aliases":["arrow_heading_up"],"tags":[],"category":"Symbols","description":"right arrow curving up","unicode_version":""},{"emoji":"⤵️","aliases":["arrow_heading_down"],"tags":[],"category":"Symbols","description":"right arrow curving down","unicode_version":""},{"emoji":"🔃","aliases":["arrows_clockwise"],"tags":[],"category":"Symbols","description":"clockwise vertical arrows","unicode_version":"6.0"},{"emoji":"🔄","aliases":["arrows_counterclockwise"],"tags":["sync"],"category":"Symbols","description":"counterclockwise arrows button","unicode_version":"6.0"},{"emoji":"🔙","aliases":["back"],"tags":[],"category":"Symbols","description":"BACK arrow","unicode_version":"6.0"},{"emoji":"🔚","aliases":["end"],"tags":[],"category":"Symbols","description":"END arrow","unicode_version":"6.0"},{"emoji":"🔛","aliases":["on"],"tags":[],"category":"Symbols","description":"ON! arrow","unicode_version":"6.0"},{"emoji":"🔜","aliases":["soon"],"tags":[],"category":"Symbols","description":"SOON arrow","unicode_version":"6.0"},{"emoji":"🔝","aliases":["top"],"tags":[],"category":"Symbols","description":"TOP arrow","unicode_version":"6.0"},{"emoji":"🛐","aliases":["place_of_worship"],"tags":[],"category":"Symbols","description":"place of worship","unicode_version":"8.0"},{"emoji":"⚛️","aliases":["atom_symbol"],"tags":[],"category":"Symbols","description":"atom symbol","unicode_version":"4.1"},{"emoji":"🕉️","aliases":["om"],"tags":[],"category":"Symbols","description":"om","unicode_version":"7.0"},{"emoji":"✡️","aliases":["star_of_david"],"tags":[],"category":"Symbols","description":"star of David","unicode_version":""},{"emoji":"☸️","aliases":["wheel_of_dharma"],"tags":[],"category":"Symbols","description":"wheel of dharma","unicode_version":""},{"emoji":"☯️","aliases":["yin_yang"],"tags":[],"category":"Symbols","description":"yin yang","unicode_version":""},{"emoji":"✝️","aliases":["latin_cross"],"tags":[],"category":"Symbols","description":"latin cross","unicode_version":""},{"emoji":"☦️","aliases":["orthodox_cross"],"tags":[],"category":"Symbols","description":"orthodox cross","unicode_version":""},{"emoji":"☪️","aliases":["star_and_crescent"],"tags":[],"category":"Symbols","description":"star and crescent","unicode_version":""},{"emoji":"☮️","aliases":["peace_symbol"],"tags":[],"category":"Symbols","description":"peace symbol","unicode_version":""},{"emoji":"🕎","aliases":["menorah"],"tags":[],"category":"Symbols","description":"menorah","unicode_version":"8.0"},{"emoji":"🔯","aliases":["six_pointed_star"],"tags":[],"category":"Symbols","description":"dotted six-pointed star","unicode_version":"6.0"},{"emoji":"♈","aliases":["aries"],"tags":[],"category":"Symbols","description":"Aries","unicode_version":""},{"emoji":"♉","aliases":["taurus"],"tags":[],"category":"Symbols","description":"Taurus","unicode_version":""},{"emoji":"♊","aliases":["gemini"],"tags":[],"category":"Symbols","description":"Gemini","unicode_version":""},{"emoji":"♋","aliases":["cancer"],"tags":[],"category":"Symbols","description":"Cancer","unicode_version":""},{"emoji":"♌","aliases":["leo"],"tags":[],"category":"Symbols","description":"Leo","unicode_version":""},{"emoji":"♍","aliases":["virgo"],"tags":[],"category":"Symbols","description":"Virgo","unicode_version":""},{"emoji":"♎","aliases":["libra"],"tags":[],"category":"Symbols","description":"Libra","unicode_version":""},{"emoji":"♏","aliases":["scorpius"],"tags":[],"category":"Symbols","description":"Scorpio","unicode_version":""},{"emoji":"♐","aliases":["sagittarius"],"tags":[],"category":"Symbols","description":"Sagittarius","unicode_version":""},{"emoji":"♑","aliases":["capricorn"],"tags":[],"category":"Symbols","description":"Capricorn","unicode_version":""},{"emoji":"♒","aliases":["aquarius"],"tags":[],"category":"Symbols","description":"Aquarius","unicode_version":""},{"emoji":"♓","aliases":["pisces"],"tags":[],"category":"Symbols","description":"Pisces","unicode_version":""},{"emoji":"⛎","aliases":["ophiuchus"],"tags":[],"category":"Symbols","description":"Ophiuchus","unicode_version":"6.0"},{"emoji":"🔀","aliases":["twisted_rightwards_arrows"],"tags":["shuffle"],"category":"Symbols","description":"shuffle tracks button","unicode_version":"6.0"},{"emoji":"🔁","aliases":["repeat"],"tags":["loop"],"category":"Symbols","description":"repeat button","unicode_version":"6.0"},{"emoji":"🔂","aliases":["repeat_one"],"tags":[],"category":"Symbols","description":"repeat single button","unicode_version":"6.0"},{"emoji":"▶️","aliases":["arrow_forward"],"tags":[],"category":"Symbols","description":"play button","unicode_version":""},{"emoji":"⏩","aliases":["fast_forward"],"tags":[],"category":"Symbols","description":"fast-forward button","unicode_version":"6.0"},{"emoji":"⏭️","aliases":["next_track_button"],"tags":[],"category":"Symbols","description":"next track button","unicode_version":"6.0"},{"emoji":"⏯️","aliases":["play_or_pause_button"],"tags":[],"category":"Symbols","description":"play or pause button","unicode_version":"6.0"},{"emoji":"◀️","aliases":["arrow_backward"],"tags":[],"category":"Symbols","description":"reverse button","unicode_version":""},{"emoji":"⏪","aliases":["rewind"],"tags":[],"category":"Symbols","description":"fast reverse button","unicode_version":"6.0"},{"emoji":"⏮️","aliases":["previous_track_button"],"tags":[],"category":"Symbols","description":"last track button","unicode_version":"6.0"},{"emoji":"🔼","aliases":["arrow_up_small"],"tags":[],"category":"Symbols","description":"upwards button","unicode_version":"6.0"},{"emoji":"⏫","aliases":["arrow_double_up"],"tags":[],"category":"Symbols","description":"fast up button","unicode_version":"6.0"},{"emoji":"🔽","aliases":["arrow_down_small"],"tags":[],"category":"Symbols","description":"downwards button","unicode_version":"6.0"},{"emoji":"⏬","aliases":["arrow_double_down"],"tags":[],"category":"Symbols","description":"fast down button","unicode_version":"6.0"},{"emoji":"⏸️","aliases":["pause_button"],"tags":[],"category":"Symbols","description":"pause button","unicode_version":"7.0"},{"emoji":"⏹️","aliases":["stop_button"],"tags":[],"category":"Symbols","description":"stop button","unicode_version":"7.0"},{"emoji":"⏺️","aliases":["record_button"],"tags":[],"category":"Symbols","description":"record button","unicode_version":"7.0"},{"emoji":"⏏️","aliases":["eject_button"],"tags":[],"category":"Symbols","description":"eject button","unicode_version":"11.0"},{"emoji":"🎦","aliases":["cinema"],"tags":["film","movie"],"category":"Symbols","description":"cinema","unicode_version":"6.0"},{"emoji":"🔅","aliases":["low_brightness"],"tags":[],"category":"Symbols","description":"dim button","unicode_version":"6.0"},{"emoji":"🔆","aliases":["high_brightness"],"tags":[],"category":"Symbols","description":"bright button","unicode_version":"6.0"},{"emoji":"📶","aliases":["signal_strength"],"tags":["wifi"],"category":"Symbols","description":"antenna bars","unicode_version":"6.0"},{"emoji":"📳","aliases":["vibration_mode"],"tags":[],"category":"Symbols","description":"vibration mode","unicode_version":"6.0"},{"emoji":"📴","aliases":["mobile_phone_off"],"tags":["mute","off"],"category":"Symbols","description":"mobile phone off","unicode_version":"6.0"},{"emoji":"♀️","aliases":["female_sign"],"tags":[],"category":"Symbols","description":"female sign","unicode_version":"11.0"},{"emoji":"♂️","aliases":["male_sign"],"tags":[],"category":"Symbols","description":"male sign","unicode_version":"11.0"},{"emoji":"⚧️","aliases":["transgender_symbol"],"tags":[],"category":"Symbols","description":"transgender symbol","unicode_version":"13.0"},{"emoji":"✖️","aliases":["heavy_multiplication_x"],"tags":[],"category":"Symbols","description":"multiply","unicode_version":""},{"emoji":"➕","aliases":["heavy_plus_sign"],"tags":[],"category":"Symbols","description":"plus","unicode_version":"6.0"},{"emoji":"➖","aliases":["heavy_minus_sign"],"tags":[],"category":"Symbols","description":"minus","unicode_version":"6.0"},{"emoji":"➗","aliases":["heavy_division_sign"],"tags":[],"category":"Symbols","description":"divide","unicode_version":"6.0"},{"emoji":"♾️","aliases":["infinity"],"tags":[],"category":"Symbols","description":"infinity","unicode_version":"11.0"},{"emoji":"‼️","aliases":["bangbang"],"tags":[],"category":"Symbols","description":"double exclamation mark","unicode_version":""},{"emoji":"⁉️","aliases":["interrobang"],"tags":[],"category":"Symbols","description":"exclamation question mark","unicode_version":"3.0"},{"emoji":"❓","aliases":["question"],"tags":["confused"],"category":"Symbols","description":"red question mark","unicode_version":"6.0"},{"emoji":"❔","aliases":["grey_question"],"tags":[],"category":"Symbols","description":"white question mark","unicode_version":"6.0"},{"emoji":"❕","aliases":["grey_exclamation"],"tags":[],"category":"Symbols","description":"white exclamation mark","unicode_version":"6.0"},{"emoji":"❗","aliases":["exclamation","heavy_exclamation_mark"],"tags":["bang"],"category":"Symbols","description":"red exclamation mark","unicode_version":"5.2"},{"emoji":"〰️","aliases":["wavy_dash"],"tags":[],"category":"Symbols","description":"wavy dash","unicode_version":""},{"emoji":"💱","aliases":["currency_exchange"],"tags":[],"category":"Symbols","description":"currency exchange","unicode_version":"6.0"},{"emoji":"💲","aliases":["heavy_dollar_sign"],"tags":[],"category":"Symbols","description":"heavy dollar sign","unicode_version":"6.0"},{"emoji":"⚕️","aliases":["medical_symbol"],"tags":[],"category":"Symbols","description":"medical symbol","unicode_version":"11.0"},{"emoji":"♻️","aliases":["recycle"],"tags":["environment","green"],"category":"Symbols","description":"recycling symbol","unicode_version":"3.2"},{"emoji":"⚜️","aliases":["fleur_de_lis"],"tags":[],"category":"Symbols","description":"fleur-de-lis","unicode_version":"4.1"},{"emoji":"🔱","aliases":["trident"],"tags":[],"category":"Symbols","description":"trident emblem","unicode_version":"6.0"},{"emoji":"📛","aliases":["name_badge"],"tags":[],"category":"Symbols","description":"name badge","unicode_version":"6.0"},{"emoji":"🔰","aliases":["beginner"],"tags":[],"category":"Symbols","description":"Japanese symbol for beginner","unicode_version":"6.0"},{"emoji":"⭕","aliases":["o"],"tags":[],"category":"Symbols","description":"hollow red circle","unicode_version":"5.2"},{"emoji":"✅","aliases":["white_check_mark"],"tags":[],"category":"Symbols","description":"check mark button","unicode_version":"6.0"},{"emoji":"☑️","aliases":["ballot_box_with_check"],"tags":[],"category":"Symbols","description":"check box with check","unicode_version":""},{"emoji":"✔️","aliases":["heavy_check_mark"],"tags":[],"category":"Symbols","description":"check mark","unicode_version":""},{"emoji":"❌","aliases":["x"],"tags":[],"category":"Symbols","description":"cross mark","unicode_version":"6.0"},{"emoji":"❎","aliases":["negative_squared_cross_mark"],"tags":[],"category":"Symbols","description":"cross mark button","unicode_version":"6.0"},{"emoji":"➰","aliases":["curly_loop"],"tags":[],"category":"Symbols","description":"curly loop","unicode_version":"6.0"},{"emoji":"➿","aliases":["loop"],"tags":[],"category":"Symbols","description":"double curly loop","unicode_version":"6.0"},{"emoji":"〽️","aliases":["part_alternation_mark"],"tags":[],"category":"Symbols","description":"part alternation mark","unicode_version":"3.2"},{"emoji":"✳️","aliases":["eight_spoked_asterisk"],"tags":[],"category":"Symbols","description":"eight-spoked asterisk","unicode_version":""},{"emoji":"✴️","aliases":["eight_pointed_black_star"],"tags":[],"category":"Symbols","description":"eight-pointed star","unicode_version":""},{"emoji":"❇️","aliases":["sparkle"],"tags":[],"category":"Symbols","description":"sparkle","unicode_version":""},{"emoji":"©️","aliases":["copyright"],"tags":[],"category":"Symbols","description":"copyright","unicode_version":""},{"emoji":"®️","aliases":["registered"],"tags":[],"category":"Symbols","description":"registered","unicode_version":""},{"emoji":"™️","aliases":["tm"],"tags":["trademark"],"category":"Symbols","description":"trade mark","unicode_version":""},{"emoji":"#️⃣","aliases":["hash"],"tags":["number"],"category":"Symbols","description":"keycap: #","unicode_version":""},{"emoji":"*️⃣","aliases":["asterisk"],"tags":[],"category":"Symbols","description":"keycap: *","unicode_version":""},{"emoji":"0️⃣","aliases":["zero"],"tags":[],"category":"Symbols","description":"keycap: 0","unicode_version":""},{"emoji":"1️⃣","aliases":["one"],"tags":[],"category":"Symbols","description":"keycap: 1","unicode_version":""},{"emoji":"2️⃣","aliases":["two"],"tags":[],"category":"Symbols","description":"keycap: 2","unicode_version":""},{"emoji":"3️⃣","aliases":["three"],"tags":[],"category":"Symbols","description":"keycap: 3","unicode_version":""},{"emoji":"4️⃣","aliases":["four"],"tags":[],"category":"Symbols","description":"keycap: 4","unicode_version":""},{"emoji":"5️⃣","aliases":["five"],"tags":[],"category":"Symbols","description":"keycap: 5","unicode_version":""},{"emoji":"6️⃣","aliases":["six"],"tags":[],"category":"Symbols","description":"keycap: 6","unicode_version":""},{"emoji":"7️⃣","aliases":["seven"],"tags":[],"category":"Symbols","description":"keycap: 7","unicode_version":""},{"emoji":"8️⃣","aliases":["eight"],"tags":[],"category":"Symbols","description":"keycap: 8","unicode_version":""},{"emoji":"9️⃣","aliases":["nine"],"tags":[],"category":"Symbols","description":"keycap: 9","unicode_version":""},{"emoji":"🔟","aliases":["keycap_ten"],"tags":[],"category":"Symbols","description":"keycap: 10","unicode_version":"6.0"},{"emoji":"🔠","aliases":["capital_abcd"],"tags":["letters"],"category":"Symbols","description":"input latin uppercase","unicode_version":"6.0"},{"emoji":"🔡","aliases":["abcd"],"tags":[],"category":"Symbols","description":"input latin lowercase","unicode_version":"6.0"},{"emoji":"🔢","aliases":["1234"],"tags":["numbers"],"category":"Symbols","description":"input numbers","unicode_version":"6.0"},{"emoji":"🔣","aliases":["symbols"],"tags":[],"category":"Symbols","description":"input symbols","unicode_version":"6.0"},{"emoji":"🔤","aliases":["abc"],"tags":["alphabet"],"category":"Symbols","description":"input latin letters","unicode_version":"6.0"},{"emoji":"🅰️","aliases":["a"],"tags":[],"category":"Symbols","description":"A button (blood type)","unicode_version":"6.0"},{"emoji":"🆎","aliases":["ab"],"tags":[],"category":"Symbols","description":"AB button (blood type)","unicode_version":"6.0"},{"emoji":"🅱️","aliases":["b"],"tags":[],"category":"Symbols","description":"B button (blood type)","unicode_version":"6.0"},{"emoji":"🆑","aliases":["cl"],"tags":[],"category":"Symbols","description":"CL button","unicode_version":"6.0"},{"emoji":"🆒","aliases":["cool"],"tags":[],"category":"Symbols","description":"COOL button","unicode_version":"6.0"},{"emoji":"🆓","aliases":["free"],"tags":[],"category":"Symbols","description":"FREE button","unicode_version":"6.0"},{"emoji":"ℹ️","aliases":["information_source"],"tags":[],"category":"Symbols","description":"information","unicode_version":"3.0"},{"emoji":"🆔","aliases":["id"],"tags":[],"category":"Symbols","description":"ID button","unicode_version":"6.0"},{"emoji":"Ⓜ️","aliases":["m"],"tags":[],"category":"Symbols","description":"circled M","unicode_version":""},{"emoji":"🆕","aliases":["new"],"tags":["fresh"],"category":"Symbols","description":"NEW button","unicode_version":"6.0"},{"emoji":"🆖","aliases":["ng"],"tags":[],"category":"Symbols","description":"NG button","unicode_version":"6.0"},{"emoji":"🅾️","aliases":["o2"],"tags":[],"category":"Symbols","description":"O button (blood type)","unicode_version":"6.0"},{"emoji":"🆗","aliases":["ok"],"tags":["yes"],"category":"Symbols","description":"OK button","unicode_version":"6.0"},{"emoji":"🅿️","aliases":["parking"],"tags":[],"category":"Symbols","description":"P button","unicode_version":"5.2"},{"emoji":"🆘","aliases":["sos"],"tags":["help","emergency"],"category":"Symbols","description":"SOS button","unicode_version":"6.0"},{"emoji":"🆙","aliases":["up"],"tags":[],"category":"Symbols","description":"UP! button","unicode_version":"6.0"},{"emoji":"🆚","aliases":["vs"],"tags":[],"category":"Symbols","description":"VS button","unicode_version":"6.0"},{"emoji":"🈁","aliases":["koko"],"tags":[],"category":"Symbols","description":"Japanese “here” button","unicode_version":"6.0"},{"emoji":"🈂️","aliases":["sa"],"tags":[],"category":"Symbols","description":"Japanese “service charge” button","unicode_version":"6.0"},{"emoji":"🈷️","aliases":["u6708"],"tags":[],"category":"Symbols","description":"Japanese “monthly amount” button","unicode_version":"6.0"},{"emoji":"🈶","aliases":["u6709"],"tags":[],"category":"Symbols","description":"Japanese “not free of charge” button","unicode_version":"6.0"},{"emoji":"🈯","aliases":["u6307"],"tags":[],"category":"Symbols","description":"Japanese “reserved” button","unicode_version":""},{"emoji":"🉐","aliases":["ideograph_advantage"],"tags":[],"category":"Symbols","description":"Japanese “bargain” button","unicode_version":"6.0"},{"emoji":"🈹","aliases":["u5272"],"tags":[],"category":"Symbols","description":"Japanese “discount” button","unicode_version":"6.0"},{"emoji":"🈚","aliases":["u7121"],"tags":[],"category":"Symbols","description":"Japanese “free of charge” button","unicode_version":""},{"emoji":"🈲","aliases":["u7981"],"tags":[],"category":"Symbols","description":"Japanese “prohibited” button","unicode_version":"6.0"},{"emoji":"🉑","aliases":["accept"],"tags":[],"category":"Symbols","description":"Japanese “acceptable” button","unicode_version":"6.0"},{"emoji":"🈸","aliases":["u7533"],"tags":[],"category":"Symbols","description":"Japanese “application” button","unicode_version":"6.0"},{"emoji":"🈴","aliases":["u5408"],"tags":[],"category":"Symbols","description":"Japanese “passing grade” button","unicode_version":"6.0"},{"emoji":"🈳","aliases":["u7a7a"],"tags":[],"category":"Symbols","description":"Japanese “vacancy” button","unicode_version":"6.0"},{"emoji":"㊗️","aliases":["congratulations"],"tags":[],"category":"Symbols","description":"Japanese “congratulations” button","unicode_version":""},{"emoji":"㊙️","aliases":["secret"],"tags":[],"category":"Symbols","description":"Japanese “secret” button","unicode_version":""},{"emoji":"🈺","aliases":["u55b6"],"tags":[],"category":"Symbols","description":"Japanese “open for business” button","unicode_version":"6.0"},{"emoji":"🈵","aliases":["u6e80"],"tags":[],"category":"Symbols","description":"Japanese “no vacancy” button","unicode_version":"6.0"},{"emoji":"🔴","aliases":["red_circle"],"tags":[],"category":"Symbols","description":"red circle","unicode_version":"6.0"},{"emoji":"🟠","aliases":["orange_circle"],"tags":[],"category":"Symbols","description":"orange circle","unicode_version":"12.0"},{"emoji":"🟡","aliases":["yellow_circle"],"tags":[],"category":"Symbols","description":"yellow circle","unicode_version":"12.0"},{"emoji":"🟢","aliases":["green_circle"],"tags":[],"category":"Symbols","description":"green circle","unicode_version":"12.0"},{"emoji":"🔵","aliases":["large_blue_circle"],"tags":[],"category":"Symbols","description":"blue circle","unicode_version":"6.0"},{"emoji":"🟣","aliases":["purple_circle"],"tags":[],"category":"Symbols","description":"purple circle","unicode_version":"12.0"},{"emoji":"🟤","aliases":["brown_circle"],"tags":[],"category":"Symbols","description":"brown circle","unicode_version":"12.0"},{"emoji":"⚫","aliases":["black_circle"],"tags":[],"category":"Symbols","description":"black circle","unicode_version":"4.1"},{"emoji":"⚪","aliases":["white_circle"],"tags":[],"category":"Symbols","description":"white circle","unicode_version":"4.1"},{"emoji":"🟥","aliases":["red_square"],"tags":[],"category":"Symbols","description":"red square","unicode_version":"12.0"},{"emoji":"🟧","aliases":["orange_square"],"tags":[],"category":"Symbols","description":"orange square","unicode_version":"12.0"},{"emoji":"🟨","aliases":["yellow_square"],"tags":[],"category":"Symbols","description":"yellow square","unicode_version":"12.0"},{"emoji":"🟩","aliases":["green_square"],"tags":[],"category":"Symbols","description":"green square","unicode_version":"12.0"},{"emoji":"🟦","aliases":["blue_square"],"tags":[],"category":"Symbols","description":"blue square","unicode_version":"12.0"},{"emoji":"🟪","aliases":["purple_square"],"tags":[],"category":"Symbols","description":"purple square","unicode_version":"12.0"},{"emoji":"🟫","aliases":["brown_square"],"tags":[],"category":"Symbols","description":"brown square","unicode_version":"12.0"},{"emoji":"⬛","aliases":["black_large_square"],"tags":[],"category":"Symbols","description":"black large square","unicode_version":"5.1"},{"emoji":"⬜","aliases":["white_large_square"],"tags":[],"category":"Symbols","description":"white large square","unicode_version":"5.1"},{"emoji":"◼️","aliases":["black_medium_square"],"tags":[],"category":"Symbols","description":"black medium square","unicode_version":"3.2"},{"emoji":"◻️","aliases":["white_medium_square"],"tags":[],"category":"Symbols","description":"white medium square","unicode_version":"3.2"},{"emoji":"◾","aliases":["black_medium_small_square"],"tags":[],"category":"Symbols","description":"black medium-small square","unicode_version":"3.2"},{"emoji":"◽","aliases":["white_medium_small_square"],"tags":[],"category":"Symbols","description":"white medium-small square","unicode_version":"3.2"},{"emoji":"▪️","aliases":["black_small_square"],"tags":[],"category":"Symbols","description":"black small square","unicode_version":""},{"emoji":"▫️","aliases":["white_small_square"],"tags":[],"category":"Symbols","description":"white small square","unicode_version":""},{"emoji":"🔶","aliases":["large_orange_diamond"],"tags":[],"category":"Symbols","description":"large orange diamond","unicode_version":"6.0"},{"emoji":"🔷","aliases":["large_blue_diamond"],"tags":[],"category":"Symbols","description":"large blue diamond","unicode_version":"6.0"},{"emoji":"🔸","aliases":["small_orange_diamond"],"tags":[],"category":"Symbols","description":"small orange diamond","unicode_version":"6.0"},{"emoji":"🔹","aliases":["small_blue_diamond"],"tags":[],"category":"Symbols","description":"small blue diamond","unicode_version":"6.0"},{"emoji":"🔺","aliases":["small_red_triangle"],"tags":[],"category":"Symbols","description":"red triangle pointed up","unicode_version":"6.0"},{"emoji":"🔻","aliases":["small_red_triangle_down"],"tags":[],"category":"Symbols","description":"red triangle pointed down","unicode_version":"6.0"},{"emoji":"💠","aliases":["diamond_shape_with_a_dot_inside"],"tags":[],"category":"Symbols","description":"diamond with a dot","unicode_version":"6.0"},{"emoji":"🔘","aliases":["radio_button"],"tags":[],"category":"Symbols","description":"radio button","unicode_version":"6.0"},{"emoji":"🔳","aliases":["white_square_button"],"tags":[],"category":"Symbols","description":"white square button","unicode_version":"6.0"},{"emoji":"🔲","aliases":["black_square_button"],"tags":[],"category":"Symbols","description":"black square button","unicode_version":"6.0"},{"emoji":"🏁","aliases":["checkered_flag"],"tags":["milestone","finish"],"category":"Flags","description":"chequered flag","unicode_version":"6.0"},{"emoji":"🚩","aliases":["triangular_flag_on_post"],"tags":[],"category":"Flags","description":"triangular flag","unicode_version":"6.0"},{"emoji":"🎌","aliases":["crossed_flags"],"tags":[],"category":"Flags","description":"crossed flags","unicode_version":"6.0"},{"emoji":"🏴","aliases":["black_flag"],"tags":[],"category":"Flags","description":"black flag","unicode_version":"7.0"},{"emoji":"🏳️","aliases":["white_flag"],"tags":[],"category":"Flags","description":"white flag","unicode_version":"7.0"},{"emoji":"🏳️‍🌈","aliases":["rainbow_flag"],"tags":["pride"],"category":"Flags","description":"rainbow flag","unicode_version":"6.0"},{"emoji":"🏳️‍⚧️","aliases":["transgender_flag"],"tags":[],"category":"Flags","description":"transgender flag","unicode_version":"13.0"},{"emoji":"🏴‍☠️","aliases":["pirate_flag"],"tags":[],"category":"Flags","description":"pirate flag","unicode_version":"11.0"},{"emoji":"🇦🇨","aliases":["ascension_island"],"tags":[],"category":"Flags","description":"flag: Ascension Island","unicode_version":"11.0"},{"emoji":"🇦🇩","aliases":["andorra"],"tags":[],"category":"Flags","description":"flag: Andorra","unicode_version":"6.0"},{"emoji":"🇦🇪","aliases":["united_arab_emirates"],"tags":[],"category":"Flags","description":"flag: United Arab Emirates","unicode_version":"6.0"},{"emoji":"🇦🇫","aliases":["afghanistan"],"tags":[],"category":"Flags","description":"flag: Afghanistan","unicode_version":"6.0"},{"emoji":"🇦🇬","aliases":["antigua_barbuda"],"tags":[],"category":"Flags","description":"flag: Antigua & Barbuda","unicode_version":"6.0"},{"emoji":"🇦🇮","aliases":["anguilla"],"tags":[],"category":"Flags","description":"flag: Anguilla","unicode_version":"6.0"},{"emoji":"🇦🇱","aliases":["albania"],"tags":[],"category":"Flags","description":"flag: Albania","unicode_version":"6.0"},{"emoji":"🇦🇲","aliases":["armenia"],"tags":[],"category":"Flags","description":"flag: Armenia","unicode_version":"6.0"},{"emoji":"🇦🇴","aliases":["angola"],"tags":[],"category":"Flags","description":"flag: Angola","unicode_version":"6.0"},{"emoji":"🇦🇶","aliases":["antarctica"],"tags":[],"category":"Flags","description":"flag: Antarctica","unicode_version":"6.0"},{"emoji":"🇦🇷","aliases":["argentina"],"tags":[],"category":"Flags","description":"flag: Argentina","unicode_version":"6.0"},{"emoji":"🇦🇸","aliases":["american_samoa"],"tags":[],"category":"Flags","description":"flag: American Samoa","unicode_version":"6.0"},{"emoji":"🇦🇹","aliases":["austria"],"tags":[],"category":"Flags","description":"flag: Austria","unicode_version":"6.0"},{"emoji":"🇦🇺","aliases":["australia"],"tags":[],"category":"Flags","description":"flag: Australia","unicode_version":"6.0"},{"emoji":"🇦🇼","aliases":["aruba"],"tags":[],"category":"Flags","description":"flag: Aruba","unicode_version":"6.0"},{"emoji":"🇦🇽","aliases":["aland_islands"],"tags":[],"category":"Flags","description":"flag: Åland Islands","unicode_version":"6.0"},{"emoji":"🇦🇿","aliases":["azerbaijan"],"tags":[],"category":"Flags","description":"flag: Azerbaijan","unicode_version":"6.0"},{"emoji":"🇧🇦","aliases":["bosnia_herzegovina"],"tags":[],"category":"Flags","description":"flag: Bosnia & Herzegovina","unicode_version":"6.0"},{"emoji":"🇧🇧","aliases":["barbados"],"tags":[],"category":"Flags","description":"flag: Barbados","unicode_version":"6.0"},{"emoji":"🇧🇩","aliases":["bangladesh"],"tags":[],"category":"Flags","description":"flag: Bangladesh","unicode_version":"6.0"},{"emoji":"🇧🇪","aliases":["belgium"],"tags":[],"category":"Flags","description":"flag: Belgium","unicode_version":"6.0"},{"emoji":"🇧🇫","aliases":["burkina_faso"],"tags":[],"category":"Flags","description":"flag: Burkina Faso","unicode_version":"6.0"},{"emoji":"🇧🇬","aliases":["bulgaria"],"tags":[],"category":"Flags","description":"flag: Bulgaria","unicode_version":"6.0"},{"emoji":"🇧🇭","aliases":["bahrain"],"tags":[],"category":"Flags","description":"flag: Bahrain","unicode_version":"6.0"},{"emoji":"🇧🇮","aliases":["burundi"],"tags":[],"category":"Flags","description":"flag: Burundi","unicode_version":"6.0"},{"emoji":"🇧🇯","aliases":["benin"],"tags":[],"category":"Flags","description":"flag: Benin","unicode_version":"6.0"},{"emoji":"🇧🇱","aliases":["st_barthelemy"],"tags":[],"category":"Flags","description":"flag: St. Barthélemy","unicode_version":"6.0"},{"emoji":"🇧🇲","aliases":["bermuda"],"tags":[],"category":"Flags","description":"flag: Bermuda","unicode_version":"6.0"},{"emoji":"🇧🇳","aliases":["brunei"],"tags":[],"category":"Flags","description":"flag: Brunei","unicode_version":"6.0"},{"emoji":"🇧🇴","aliases":["bolivia"],"tags":[],"category":"Flags","description":"flag: Bolivia","unicode_version":"6.0"},{"emoji":"🇧🇶","aliases":["caribbean_netherlands"],"tags":[],"category":"Flags","description":"flag: Caribbean Netherlands","unicode_version":"6.0"},{"emoji":"🇧🇷","aliases":["brazil"],"tags":[],"category":"Flags","description":"flag: Brazil","unicode_version":"6.0"},{"emoji":"🇧🇸","aliases":["bahamas"],"tags":[],"category":"Flags","description":"flag: Bahamas","unicode_version":"6.0"},{"emoji":"🇧🇹","aliases":["bhutan"],"tags":[],"category":"Flags","description":"flag: Bhutan","unicode_version":"6.0"},{"emoji":"🇧🇻","aliases":["bouvet_island"],"tags":[],"category":"Flags","description":"flag: Bouvet Island","unicode_version":"11.0"},{"emoji":"🇧🇼","aliases":["botswana"],"tags":[],"category":"Flags","description":"flag: Botswana","unicode_version":"6.0"},{"emoji":"🇧🇾","aliases":["belarus"],"tags":[],"category":"Flags","description":"flag: Belarus","unicode_version":"6.0"},{"emoji":"🇧🇿","aliases":["belize"],"tags":[],"category":"Flags","description":"flag: Belize","unicode_version":"6.0"},{"emoji":"🇨🇦","aliases":["canada"],"tags":[],"category":"Flags","description":"flag: Canada","unicode_version":"6.0"},{"emoji":"🇨🇨","aliases":["cocos_islands"],"tags":["keeling"],"category":"Flags","description":"flag: Cocos (Keeling) Islands","unicode_version":"6.0"},{"emoji":"🇨🇩","aliases":["congo_kinshasa"],"tags":[],"category":"Flags","description":"flag: Congo - Kinshasa","unicode_version":"6.0"},{"emoji":"🇨🇫","aliases":["central_african_republic"],"tags":[],"category":"Flags","description":"flag: Central African Republic","unicode_version":"6.0"},{"emoji":"🇨🇬","aliases":["congo_brazzaville"],"tags":[],"category":"Flags","description":"flag: Congo - Brazzaville","unicode_version":"6.0"},{"emoji":"🇨🇭","aliases":["switzerland"],"tags":[],"category":"Flags","description":"flag: Switzerland","unicode_version":"6.0"},{"emoji":"🇨🇮","aliases":["cote_divoire"],"tags":["ivory"],"category":"Flags","description":"flag: Côte d’Ivoire","unicode_version":"6.0"},{"emoji":"🇨🇰","aliases":["cook_islands"],"tags":[],"category":"Flags","description":"flag: Cook Islands","unicode_version":"6.0"},{"emoji":"🇨🇱","aliases":["chile"],"tags":[],"category":"Flags","description":"flag: Chile","unicode_version":"6.0"},{"emoji":"🇨🇲","aliases":["cameroon"],"tags":[],"category":"Flags","description":"flag: Cameroon","unicode_version":"6.0"},{"emoji":"🇨🇳","aliases":["cn"],"tags":["china"],"category":"Flags","description":"flag: China","unicode_version":"6.0"},{"emoji":"🇨🇴","aliases":["colombia"],"tags":[],"category":"Flags","description":"flag: Colombia","unicode_version":"6.0"},{"emoji":"🇨🇵","aliases":["clipperton_island"],"tags":[],"category":"Flags","description":"flag: Clipperton Island","unicode_version":"11.0"},{"emoji":"🇨🇷","aliases":["costa_rica"],"tags":[],"category":"Flags","description":"flag: Costa Rica","unicode_version":"6.0"},{"emoji":"🇨🇺","aliases":["cuba"],"tags":[],"category":"Flags","description":"flag: Cuba","unicode_version":"6.0"},{"emoji":"🇨🇻","aliases":["cape_verde"],"tags":[],"category":"Flags","description":"flag: Cape Verde","unicode_version":"6.0"},{"emoji":"🇨🇼","aliases":["curacao"],"tags":[],"category":"Flags","description":"flag: Curaçao","unicode_version":"6.0"},{"emoji":"🇨🇽","aliases":["christmas_island"],"tags":[],"category":"Flags","description":"flag: Christmas Island","unicode_version":"6.0"},{"emoji":"🇨🇾","aliases":["cyprus"],"tags":[],"category":"Flags","description":"flag: Cyprus","unicode_version":"6.0"},{"emoji":"🇨🇿","aliases":["czech_republic"],"tags":[],"category":"Flags","description":"flag: Czechia","unicode_version":"6.0"},{"emoji":"🇩🇪","aliases":["de"],"tags":["flag","germany"],"category":"Flags","description":"flag: Germany","unicode_version":"6.0"},{"emoji":"🇩🇬","aliases":["diego_garcia"],"tags":[],"category":"Flags","description":"flag: Diego Garcia","unicode_version":"11.0"},{"emoji":"🇩🇯","aliases":["djibouti"],"tags":[],"category":"Flags","description":"flag: Djibouti","unicode_version":"6.0"},{"emoji":"🇩🇰","aliases":["denmark"],"tags":[],"category":"Flags","description":"flag: Denmark","unicode_version":"6.0"},{"emoji":"🇩🇲","aliases":["dominica"],"tags":[],"category":"Flags","description":"flag: Dominica","unicode_version":"6.0"},{"emoji":"🇩🇴","aliases":["dominican_republic"],"tags":[],"category":"Flags","description":"flag: Dominican Republic","unicode_version":"6.0"},{"emoji":"🇩🇿","aliases":["algeria"],"tags":[],"category":"Flags","description":"flag: Algeria","unicode_version":"6.0"},{"emoji":"🇪🇦","aliases":["ceuta_melilla"],"tags":[],"category":"Flags","description":"flag: Ceuta & Melilla","unicode_version":"11.0"},{"emoji":"🇪🇨","aliases":["ecuador"],"tags":[],"category":"Flags","description":"flag: Ecuador","unicode_version":"6.0"},{"emoji":"🇪🇪","aliases":["estonia"],"tags":[],"category":"Flags","description":"flag: Estonia","unicode_version":"6.0"},{"emoji":"🇪🇬","aliases":["egypt"],"tags":[],"category":"Flags","description":"flag: Egypt","unicode_version":"6.0"},{"emoji":"🇪🇭","aliases":["western_sahara"],"tags":[],"category":"Flags","description":"flag: Western Sahara","unicode_version":"6.0"},{"emoji":"🇪🇷","aliases":["eritrea"],"tags":[],"category":"Flags","description":"flag: Eritrea","unicode_version":"6.0"},{"emoji":"🇪🇸","aliases":["es"],"tags":["spain"],"category":"Flags","description":"flag: Spain","unicode_version":"6.0"},{"emoji":"🇪🇹","aliases":["ethiopia"],"tags":[],"category":"Flags","description":"flag: Ethiopia","unicode_version":"6.0"},{"emoji":"🇪🇺","aliases":["eu","european_union"],"tags":[],"category":"Flags","description":"flag: European Union","unicode_version":"6.0"},{"emoji":"🇫🇮","aliases":["finland"],"tags":[],"category":"Flags","description":"flag: Finland","unicode_version":"6.0"},{"emoji":"🇫🇯","aliases":["fiji"],"tags":[],"category":"Flags","description":"flag: Fiji","unicode_version":"6.0"},{"emoji":"🇫🇰","aliases":["falkland_islands"],"tags":[],"category":"Flags","description":"flag: Falkland Islands","unicode_version":"6.0"},{"emoji":"🇫🇲","aliases":["micronesia"],"tags":[],"category":"Flags","description":"flag: Micronesia","unicode_version":"6.0"},{"emoji":"🇫🇴","aliases":["faroe_islands"],"tags":[],"category":"Flags","description":"flag: Faroe Islands","unicode_version":"6.0"},{"emoji":"🇫🇷","aliases":["fr"],"tags":["france","french"],"category":"Flags","description":"flag: France","unicode_version":"6.0"},{"emoji":"🇬🇦","aliases":["gabon"],"tags":[],"category":"Flags","description":"flag: Gabon","unicode_version":"6.0"},{"emoji":"🇬🇧","aliases":["gb","uk"],"tags":["flag","british"],"category":"Flags","description":"flag: United Kingdom","unicode_version":"6.0"},{"emoji":"🇬🇩","aliases":["grenada"],"tags":[],"category":"Flags","description":"flag: Grenada","unicode_version":"6.0"},{"emoji":"🇬🇪","aliases":["georgia"],"tags":[],"category":"Flags","description":"flag: Georgia","unicode_version":"6.0"},{"emoji":"🇬🇫","aliases":["french_guiana"],"tags":[],"category":"Flags","description":"flag: French Guiana","unicode_version":"6.0"},{"emoji":"🇬🇬","aliases":["guernsey"],"tags":[],"category":"Flags","description":"flag: Guernsey","unicode_version":"6.0"},{"emoji":"🇬🇭","aliases":["ghana"],"tags":[],"category":"Flags","description":"flag: Ghana","unicode_version":"6.0"},{"emoji":"🇬🇮","aliases":["gibraltar"],"tags":[],"category":"Flags","description":"flag: Gibraltar","unicode_version":"6.0"},{"emoji":"🇬🇱","aliases":["greenland"],"tags":[],"category":"Flags","description":"flag: Greenland","unicode_version":"6.0"},{"emoji":"🇬🇲","aliases":["gambia"],"tags":[],"category":"Flags","description":"flag: Gambia","unicode_version":"6.0"},{"emoji":"🇬🇳","aliases":["guinea"],"tags":[],"category":"Flags","description":"flag: Guinea","unicode_version":"6.0"},{"emoji":"🇬🇵","aliases":["guadeloupe"],"tags":[],"category":"Flags","description":"flag: Guadeloupe","unicode_version":"6.0"},{"emoji":"🇬🇶","aliases":["equatorial_guinea"],"tags":[],"category":"Flags","description":"flag: Equatorial Guinea","unicode_version":"6.0"},{"emoji":"🇬🇷","aliases":["greece"],"tags":[],"category":"Flags","description":"flag: Greece","unicode_version":"6.0"},{"emoji":"🇬🇸","aliases":["south_georgia_south_sandwich_islands"],"tags":[],"category":"Flags","description":"flag: South Georgia & South Sandwich Islands","unicode_version":"6.0"},{"emoji":"🇬🇹","aliases":["guatemala"],"tags":[],"category":"Flags","description":"flag: Guatemala","unicode_version":"6.0"},{"emoji":"🇬🇺","aliases":["guam"],"tags":[],"category":"Flags","description":"flag: Guam","unicode_version":"6.0"},{"emoji":"🇬🇼","aliases":["guinea_bissau"],"tags":[],"category":"Flags","description":"flag: Guinea-Bissau","unicode_version":"6.0"},{"emoji":"🇬🇾","aliases":["guyana"],"tags":[],"category":"Flags","description":"flag: Guyana","unicode_version":"6.0"},{"emoji":"🇭🇰","aliases":["hong_kong"],"tags":[],"category":"Flags","description":"flag: Hong Kong SAR China","unicode_version":"6.0"},{"emoji":"🇭🇲","aliases":["heard_mcdonald_islands"],"tags":[],"category":"Flags","description":"flag: Heard & McDonald Islands","unicode_version":"11.0"},{"emoji":"🇭🇳","aliases":["honduras"],"tags":[],"category":"Flags","description":"flag: Honduras","unicode_version":"6.0"},{"emoji":"🇭🇷","aliases":["croatia"],"tags":[],"category":"Flags","description":"flag: Croatia","unicode_version":"6.0"},{"emoji":"🇭🇹","aliases":["haiti"],"tags":[],"category":"Flags","description":"flag: Haiti","unicode_version":"6.0"},{"emoji":"🇭🇺","aliases":["hungary"],"tags":[],"category":"Flags","description":"flag: Hungary","unicode_version":"6.0"},{"emoji":"🇮🇨","aliases":["canary_islands"],"tags":[],"category":"Flags","description":"flag: Canary Islands","unicode_version":"6.0"},{"emoji":"🇮🇩","aliases":["indonesia"],"tags":[],"category":"Flags","description":"flag: Indonesia","unicode_version":"6.0"},{"emoji":"🇮🇪","aliases":["ireland"],"tags":[],"category":"Flags","description":"flag: Ireland","unicode_version":"6.0"},{"emoji":"🇮🇱","aliases":["israel"],"tags":[],"category":"Flags","description":"flag: Israel","unicode_version":"6.0"},{"emoji":"🇮🇲","aliases":["isle_of_man"],"tags":[],"category":"Flags","description":"flag: Isle of Man","unicode_version":"6.0"},{"emoji":"🇮🇳","aliases":["india"],"tags":[],"category":"Flags","description":"flag: India","unicode_version":"6.0"},{"emoji":"🇮🇴","aliases":["british_indian_ocean_territory"],"tags":[],"category":"Flags","description":"flag: British Indian Ocean Territory","unicode_version":"6.0"},{"emoji":"🇮🇶","aliases":["iraq"],"tags":[],"category":"Flags","description":"flag: Iraq","unicode_version":"6.0"},{"emoji":"🇮🇷","aliases":["iran"],"tags":[],"category":"Flags","description":"flag: Iran","unicode_version":"6.0"},{"emoji":"🇮🇸","aliases":["iceland"],"tags":[],"category":"Flags","description":"flag: Iceland","unicode_version":"6.0"},{"emoji":"🇮🇹","aliases":["it"],"tags":["italy"],"category":"Flags","description":"flag: Italy","unicode_version":"6.0"},{"emoji":"🇯🇪","aliases":["jersey"],"tags":[],"category":"Flags","description":"flag: Jersey","unicode_version":"6.0"},{"emoji":"🇯🇲","aliases":["jamaica"],"tags":[],"category":"Flags","description":"flag: Jamaica","unicode_version":"6.0"},{"emoji":"🇯🇴","aliases":["jordan"],"tags":[],"category":"Flags","description":"flag: Jordan","unicode_version":"6.0"},{"emoji":"🇯🇵","aliases":["jp"],"tags":["japan"],"category":"Flags","description":"flag: Japan","unicode_version":"6.0"},{"emoji":"🇰🇪","aliases":["kenya"],"tags":[],"category":"Flags","description":"flag: Kenya","unicode_version":"6.0"},{"emoji":"🇰🇬","aliases":["kyrgyzstan"],"tags":[],"category":"Flags","description":"flag: Kyrgyzstan","unicode_version":"6.0"},{"emoji":"🇰🇭","aliases":["cambodia"],"tags":[],"category":"Flags","description":"flag: Cambodia","unicode_version":"6.0"},{"emoji":"🇰🇮","aliases":["kiribati"],"tags":[],"category":"Flags","description":"flag: Kiribati","unicode_version":"6.0"},{"emoji":"🇰🇲","aliases":["comoros"],"tags":[],"category":"Flags","description":"flag: Comoros","unicode_version":"6.0"},{"emoji":"🇰🇳","aliases":["st_kitts_nevis"],"tags":[],"category":"Flags","description":"flag: St. Kitts & Nevis","unicode_version":"6.0"},{"emoji":"🇰🇵","aliases":["north_korea"],"tags":[],"category":"Flags","description":"flag: North Korea","unicode_version":"6.0"},{"emoji":"🇰🇷","aliases":["kr"],"tags":["korea"],"category":"Flags","description":"flag: South Korea","unicode_version":"6.0"},{"emoji":"🇰🇼","aliases":["kuwait"],"tags":[],"category":"Flags","description":"flag: Kuwait","unicode_version":"6.0"},{"emoji":"🇰🇾","aliases":["cayman_islands"],"tags":[],"category":"Flags","description":"flag: Cayman Islands","unicode_version":"6.0"},{"emoji":"🇰🇿","aliases":["kazakhstan"],"tags":[],"category":"Flags","description":"flag: Kazakhstan","unicode_version":"6.0"},{"emoji":"🇱🇦","aliases":["laos"],"tags":[],"category":"Flags","description":"flag: Laos","unicode_version":"6.0"},{"emoji":"🇱🇧","aliases":["lebanon"],"tags":[],"category":"Flags","description":"flag: Lebanon","unicode_version":"6.0"},{"emoji":"🇱🇨","aliases":["st_lucia"],"tags":[],"category":"Flags","description":"flag: St. Lucia","unicode_version":"6.0"},{"emoji":"🇱🇮","aliases":["liechtenstein"],"tags":[],"category":"Flags","description":"flag: Liechtenstein","unicode_version":"6.0"},{"emoji":"🇱🇰","aliases":["sri_lanka"],"tags":[],"category":"Flags","description":"flag: Sri Lanka","unicode_version":"6.0"},{"emoji":"🇱🇷","aliases":["liberia"],"tags":[],"category":"Flags","description":"flag: Liberia","unicode_version":"6.0"},{"emoji":"🇱🇸","aliases":["lesotho"],"tags":[],"category":"Flags","description":"flag: Lesotho","unicode_version":"6.0"},{"emoji":"🇱🇹","aliases":["lithuania"],"tags":[],"category":"Flags","description":"flag: Lithuania","unicode_version":"6.0"},{"emoji":"🇱🇺","aliases":["luxembourg"],"tags":[],"category":"Flags","description":"flag: Luxembourg","unicode_version":"6.0"},{"emoji":"🇱🇻","aliases":["latvia"],"tags":[],"category":"Flags","description":"flag: Latvia","unicode_version":"6.0"},{"emoji":"🇱🇾","aliases":["libya"],"tags":[],"category":"Flags","description":"flag: Libya","unicode_version":"6.0"},{"emoji":"🇲🇦","aliases":["morocco"],"tags":[],"category":"Flags","description":"flag: Morocco","unicode_version":"6.0"},{"emoji":"🇲🇨","aliases":["monaco"],"tags":[],"category":"Flags","description":"flag: Monaco","unicode_version":"6.0"},{"emoji":"🇲🇩","aliases":["moldova"],"tags":[],"category":"Flags","description":"flag: Moldova","unicode_version":"6.0"},{"emoji":"🇲🇪","aliases":["montenegro"],"tags":[],"category":"Flags","description":"flag: Montenegro","unicode_version":"6.0"},{"emoji":"🇲🇫","aliases":["st_martin"],"tags":[],"category":"Flags","description":"flag: St. Martin","unicode_version":"11.0"},{"emoji":"🇲🇬","aliases":["madagascar"],"tags":[],"category":"Flags","description":"flag: Madagascar","unicode_version":"6.0"},{"emoji":"🇲🇭","aliases":["marshall_islands"],"tags":[],"category":"Flags","description":"flag: Marshall Islands","unicode_version":"6.0"},{"emoji":"🇲🇰","aliases":["macedonia"],"tags":[],"category":"Flags","description":"flag: North Macedonia","unicode_version":"6.0"},{"emoji":"🇲🇱","aliases":["mali"],"tags":[],"category":"Flags","description":"flag: Mali","unicode_version":"6.0"},{"emoji":"🇲🇲","aliases":["myanmar"],"tags":["burma"],"category":"Flags","description":"flag: Myanmar (Burma)","unicode_version":"6.0"},{"emoji":"🇲🇳","aliases":["mongolia"],"tags":[],"category":"Flags","description":"flag: Mongolia","unicode_version":"6.0"},{"emoji":"🇲🇴","aliases":["macau"],"tags":[],"category":"Flags","description":"flag: Macao SAR China","unicode_version":"6.0"},{"emoji":"🇲🇵","aliases":["northern_mariana_islands"],"tags":[],"category":"Flags","description":"flag: Northern Mariana Islands","unicode_version":"6.0"},{"emoji":"🇲🇶","aliases":["martinique"],"tags":[],"category":"Flags","description":"flag: Martinique","unicode_version":"6.0"},{"emoji":"🇲🇷","aliases":["mauritania"],"tags":[],"category":"Flags","description":"flag: Mauritania","unicode_version":"6.0"},{"emoji":"🇲🇸","aliases":["montserrat"],"tags":[],"category":"Flags","description":"flag: Montserrat","unicode_version":"6.0"},{"emoji":"🇲🇹","aliases":["malta"],"tags":[],"category":"Flags","description":"flag: Malta","unicode_version":"6.0"},{"emoji":"🇲🇺","aliases":["mauritius"],"tags":[],"category":"Flags","description":"flag: Mauritius","unicode_version":"6.0"},{"emoji":"🇲🇻","aliases":["maldives"],"tags":[],"category":"Flags","description":"flag: Maldives","unicode_version":"6.0"},{"emoji":"🇲🇼","aliases":["malawi"],"tags":[],"category":"Flags","description":"flag: Malawi","unicode_version":"6.0"},{"emoji":"🇲🇽","aliases":["mexico"],"tags":[],"category":"Flags","description":"flag: Mexico","unicode_version":"6.0"},{"emoji":"🇲🇾","aliases":["malaysia"],"tags":[],"category":"Flags","description":"flag: Malaysia","unicode_version":"6.0"},{"emoji":"🇲🇿","aliases":["mozambique"],"tags":[],"category":"Flags","description":"flag: Mozambique","unicode_version":"6.0"},{"emoji":"🇳🇦","aliases":["namibia"],"tags":[],"category":"Flags","description":"flag: Namibia","unicode_version":"6.0"},{"emoji":"🇳🇨","aliases":["new_caledonia"],"tags":[],"category":"Flags","description":"flag: New Caledonia","unicode_version":"6.0"},{"emoji":"🇳🇪","aliases":["niger"],"tags":[],"category":"Flags","description":"flag: Niger","unicode_version":"6.0"},{"emoji":"🇳🇫","aliases":["norfolk_island"],"tags":[],"category":"Flags","description":"flag: Norfolk Island","unicode_version":"6.0"},{"emoji":"🇳🇬","aliases":["nigeria"],"tags":[],"category":"Flags","description":"flag: Nigeria","unicode_version":"6.0"},{"emoji":"🇳🇮","aliases":["nicaragua"],"tags":[],"category":"Flags","description":"flag: Nicaragua","unicode_version":"6.0"},{"emoji":"🇳🇱","aliases":["netherlands"],"tags":[],"category":"Flags","description":"flag: Netherlands","unicode_version":"6.0"},{"emoji":"🇳🇴","aliases":["norway"],"tags":[],"category":"Flags","description":"flag: Norway","unicode_version":"6.0"},{"emoji":"🇳🇵","aliases":["nepal"],"tags":[],"category":"Flags","description":"flag: Nepal","unicode_version":"6.0"},{"emoji":"🇳🇷","aliases":["nauru"],"tags":[],"category":"Flags","description":"flag: Nauru","unicode_version":"6.0"},{"emoji":"🇳🇺","aliases":["niue"],"tags":[],"category":"Flags","description":"flag: Niue","unicode_version":"6.0"},{"emoji":"🇳🇿","aliases":["new_zealand"],"tags":[],"category":"Flags","description":"flag: New Zealand","unicode_version":"6.0"},{"emoji":"🇴🇲","aliases":["oman"],"tags":[],"category":"Flags","description":"flag: Oman","unicode_version":"6.0"},{"emoji":"🇵🇦","aliases":["panama"],"tags":[],"category":"Flags","description":"flag: Panama","unicode_version":"6.0"},{"emoji":"🇵🇪","aliases":["peru"],"tags":[],"category":"Flags","description":"flag: Peru","unicode_version":"6.0"},{"emoji":"🇵🇫","aliases":["french_polynesia"],"tags":[],"category":"Flags","description":"flag: French Polynesia","unicode_version":"6.0"},{"emoji":"🇵🇬","aliases":["papua_new_guinea"],"tags":[],"category":"Flags","description":"flag: Papua New Guinea","unicode_version":"6.0"},{"emoji":"🇵🇭","aliases":["philippines"],"tags":[],"category":"Flags","description":"flag: Philippines","unicode_version":"6.0"},{"emoji":"🇵🇰","aliases":["pakistan"],"tags":[],"category":"Flags","description":"flag: Pakistan","unicode_version":"6.0"},{"emoji":"🇵🇱","aliases":["poland"],"tags":[],"category":"Flags","description":"flag: Poland","unicode_version":"6.0"},{"emoji":"🇵🇲","aliases":["st_pierre_miquelon"],"tags":[],"category":"Flags","description":"flag: St. Pierre & Miquelon","unicode_version":"6.0"},{"emoji":"🇵🇳","aliases":["pitcairn_islands"],"tags":[],"category":"Flags","description":"flag: Pitcairn Islands","unicode_version":"6.0"},{"emoji":"🇵🇷","aliases":["puerto_rico"],"tags":[],"category":"Flags","description":"flag: Puerto Rico","unicode_version":"6.0"},{"emoji":"🇵🇸","aliases":["palestinian_territories"],"tags":[],"category":"Flags","description":"flag: Palestinian Territories","unicode_version":"6.0"},{"emoji":"🇵🇹","aliases":["portugal"],"tags":[],"category":"Flags","description":"flag: Portugal","unicode_version":"6.0"},{"emoji":"🇵🇼","aliases":["palau"],"tags":[],"category":"Flags","description":"flag: Palau","unicode_version":"6.0"},{"emoji":"🇵🇾","aliases":["paraguay"],"tags":[],"category":"Flags","description":"flag: Paraguay","unicode_version":"6.0"},{"emoji":"🇶🇦","aliases":["qatar"],"tags":[],"category":"Flags","description":"flag: Qatar","unicode_version":"6.0"},{"emoji":"🇷🇪","aliases":["reunion"],"tags":[],"category":"Flags","description":"flag: Réunion","unicode_version":"6.0"},{"emoji":"🇷🇴","aliases":["romania"],"tags":[],"category":"Flags","description":"flag: Romania","unicode_version":"6.0"},{"emoji":"🇷🇸","aliases":["serbia"],"tags":[],"category":"Flags","description":"flag: Serbia","unicode_version":"6.0"},{"emoji":"🇷🇺","aliases":["ru"],"tags":["russia"],"category":"Flags","description":"flag: Russia","unicode_version":"6.0"},{"emoji":"🇷🇼","aliases":["rwanda"],"tags":[],"category":"Flags","description":"flag: Rwanda","unicode_version":"6.0"},{"emoji":"🇸🇦","aliases":["saudi_arabia"],"tags":[],"category":"Flags","description":"flag: Saudi Arabia","unicode_version":"6.0"},{"emoji":"🇸🇧","aliases":["solomon_islands"],"tags":[],"category":"Flags","description":"flag: Solomon Islands","unicode_version":"6.0"},{"emoji":"🇸🇨","aliases":["seychelles"],"tags":[],"category":"Flags","description":"flag: Seychelles","unicode_version":"6.0"},{"emoji":"🇸🇩","aliases":["sudan"],"tags":[],"category":"Flags","description":"flag: Sudan","unicode_version":"6.0"},{"emoji":"🇸🇪","aliases":["sweden"],"tags":[],"category":"Flags","description":"flag: Sweden","unicode_version":"6.0"},{"emoji":"🇸🇬","aliases":["singapore"],"tags":[],"category":"Flags","description":"flag: Singapore","unicode_version":"6.0"},{"emoji":"🇸🇭","aliases":["st_helena"],"tags":[],"category":"Flags","description":"flag: St. Helena","unicode_version":"6.0"},{"emoji":"🇸🇮","aliases":["slovenia"],"tags":[],"category":"Flags","description":"flag: Slovenia","unicode_version":"6.0"},{"emoji":"🇸🇯","aliases":["svalbard_jan_mayen"],"tags":[],"category":"Flags","description":"flag: Svalbard & Jan Mayen","unicode_version":"11.0"},{"emoji":"🇸🇰","aliases":["slovakia"],"tags":[],"category":"Flags","description":"flag: Slovakia","unicode_version":"6.0"},{"emoji":"🇸🇱","aliases":["sierra_leone"],"tags":[],"category":"Flags","description":"flag: Sierra Leone","unicode_version":"6.0"},{"emoji":"🇸🇲","aliases":["san_marino"],"tags":[],"category":"Flags","description":"flag: San Marino","unicode_version":"6.0"},{"emoji":"🇸🇳","aliases":["senegal"],"tags":[],"category":"Flags","description":"flag: Senegal","unicode_version":"6.0"},{"emoji":"🇸🇴","aliases":["somalia"],"tags":[],"category":"Flags","description":"flag: Somalia","unicode_version":"6.0"},{"emoji":"🇸🇷","aliases":["suriname"],"tags":[],"category":"Flags","description":"flag: Suriname","unicode_version":"6.0"},{"emoji":"🇸🇸","aliases":["south_sudan"],"tags":[],"category":"Flags","description":"flag: South Sudan","unicode_version":"6.0"},{"emoji":"🇸🇹","aliases":["sao_tome_principe"],"tags":[],"category":"Flags","description":"flag: São Tomé & Príncipe","unicode_version":"6.0"},{"emoji":"🇸🇻","aliases":["el_salvador"],"tags":[],"category":"Flags","description":"flag: El Salvador","unicode_version":"6.0"},{"emoji":"🇸🇽","aliases":["sint_maarten"],"tags":[],"category":"Flags","description":"flag: Sint Maarten","unicode_version":"6.0"},{"emoji":"🇸🇾","aliases":["syria"],"tags":[],"category":"Flags","description":"flag: Syria","unicode_version":"6.0"},{"emoji":"🇸🇿","aliases":["swaziland"],"tags":[],"category":"Flags","description":"flag: Eswatini","unicode_version":"6.0"},{"emoji":"🇹🇦","aliases":["tristan_da_cunha"],"tags":[],"category":"Flags","description":"flag: Tristan da Cunha","unicode_version":"11.0"},{"emoji":"🇹🇨","aliases":["turks_caicos_islands"],"tags":[],"category":"Flags","description":"flag: Turks & Caicos Islands","unicode_version":"6.0"},{"emoji":"🇹🇩","aliases":["chad"],"tags":[],"category":"Flags","description":"flag: Chad","unicode_version":"6.0"},{"emoji":"🇹🇫","aliases":["french_southern_territories"],"tags":[],"category":"Flags","description":"flag: French Southern Territories","unicode_version":"6.0"},{"emoji":"🇹🇬","aliases":["togo"],"tags":[],"category":"Flags","description":"flag: Togo","unicode_version":"6.0"},{"emoji":"🇹🇭","aliases":["thailand"],"tags":[],"category":"Flags","description":"flag: Thailand","unicode_version":"6.0"},{"emoji":"🇹🇯","aliases":["tajikistan"],"tags":[],"category":"Flags","description":"flag: Tajikistan","unicode_version":"6.0"},{"emoji":"🇹🇰","aliases":["tokelau"],"tags":[],"category":"Flags","description":"flag: Tokelau","unicode_version":"6.0"},{"emoji":"🇹🇱","aliases":["timor_leste"],"tags":[],"category":"Flags","description":"flag: Timor-Leste","unicode_version":"6.0"},{"emoji":"🇹🇲","aliases":["turkmenistan"],"tags":[],"category":"Flags","description":"flag: Turkmenistan","unicode_version":"6.0"},{"emoji":"🇹🇳","aliases":["tunisia"],"tags":[],"category":"Flags","description":"flag: Tunisia","unicode_version":"6.0"},{"emoji":"🇹🇴","aliases":["tonga"],"tags":[],"category":"Flags","description":"flag: Tonga","unicode_version":"6.0"},{"emoji":"🇹🇷","aliases":["tr"],"tags":["turkey"],"category":"Flags","description":"flag: Turkey","unicode_version":"8.0"},{"emoji":"🇹🇹","aliases":["trinidad_tobago"],"tags":[],"category":"Flags","description":"flag: Trinidad & Tobago","unicode_version":"6.0"},{"emoji":"🇹🇻","aliases":["tuvalu"],"tags":[],"category":"Flags","description":"flag: Tuvalu","unicode_version":"6.0"},{"emoji":"🇹🇼","aliases":["taiwan"],"tags":[],"category":"Flags","description":"flag: Taiwan","unicode_version":"6.0"},{"emoji":"🇹🇿","aliases":["tanzania"],"tags":[],"category":"Flags","description":"flag: Tanzania","unicode_version":"6.0"},{"emoji":"🇺🇦","aliases":["ukraine"],"tags":[],"category":"Flags","description":"flag: Ukraine","unicode_version":"6.0"},{"emoji":"🇺🇬","aliases":["uganda"],"tags":[],"category":"Flags","description":"flag: Uganda","unicode_version":"6.0"},{"emoji":"🇺🇲","aliases":["us_outlying_islands"],"tags":[],"category":"Flags","description":"flag: U.S. Outlying Islands","unicode_version":"11.0"},{"emoji":"🇺🇳","aliases":["united_nations"],"tags":[],"category":"Flags","description":"flag: United Nations","unicode_version":"11.0"},{"emoji":"🇺🇸","aliases":["us"],"tags":["flag","united","america"],"category":"Flags","description":"flag: United States","unicode_version":"6.0"},{"emoji":"🇺🇾","aliases":["uruguay"],"tags":[],"category":"Flags","description":"flag: Uruguay","unicode_version":"6.0"},{"emoji":"🇺🇿","aliases":["uzbekistan"],"tags":[],"category":"Flags","description":"flag: Uzbekistan","unicode_version":"6.0"},{"emoji":"🇻🇦","aliases":["vatican_city"],"tags":[],"category":"Flags","description":"flag: Vatican City","unicode_version":"6.0"},{"emoji":"🇻🇨","aliases":["st_vincent_grenadines"],"tags":[],"category":"Flags","description":"flag: St. Vincent & Grenadines","unicode_version":"6.0"},{"emoji":"🇻🇪","aliases":["venezuela"],"tags":[],"category":"Flags","description":"flag: Venezuela","unicode_version":"6.0"},{"emoji":"🇻🇬","aliases":["british_virgin_islands"],"tags":[],"category":"Flags","description":"flag: British Virgin Islands","unicode_version":"6.0"},{"emoji":"🇻🇮","aliases":["us_virgin_islands"],"tags":[],"category":"Flags","description":"flag: U.S. Virgin Islands","unicode_version":"6.0"},{"emoji":"🇻🇳","aliases":["vietnam"],"tags":[],"category":"Flags","description":"flag: Vietnam","unicode_version":"6.0"},{"emoji":"🇻🇺","aliases":["vanuatu"],"tags":[],"category":"Flags","description":"flag: Vanuatu","unicode_version":"6.0"},{"emoji":"🇼🇫","aliases":["wallis_futuna"],"tags":[],"category":"Flags","description":"flag: Wallis & Futuna","unicode_version":"6.0"},{"emoji":"🇼🇸","aliases":["samoa"],"tags":[],"category":"Flags","description":"flag: Samoa","unicode_version":"6.0"},{"emoji":"🇽🇰","aliases":["kosovo"],"tags":[],"category":"Flags","description":"flag: Kosovo","unicode_version":"6.0"},{"emoji":"🇾🇪","aliases":["yemen"],"tags":[],"category":"Flags","description":"flag: Yemen","unicode_version":"6.0"},{"emoji":"🇾🇹","aliases":["mayotte"],"tags":[],"category":"Flags","description":"flag: Mayotte","unicode_version":"6.0"},{"emoji":"🇿🇦","aliases":["south_africa"],"tags":[],"category":"Flags","description":"flag: South Africa","unicode_version":"6.0"},{"emoji":"🇿🇲","aliases":["zambia"],"tags":[],"category":"Flags","description":"flag: Zambia","unicode_version":"6.0"},{"emoji":"🇿🇼","aliases":["zimbabwe"],"tags":[],"category":"Flags","description":"flag: Zimbabwe","unicode_version":"6.0"},{"emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","aliases":["england"],"tags":[],"category":"Flags","description":"flag: England","unicode_version":"11.0"},{"emoji":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","aliases":["scotland"],"tags":[],"category":"Flags","description":"flag: Scotland","unicode_version":"11.0"},{"emoji":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","aliases":["wales"],"tags":[],"category":"Flags","description":"flag: Wales","unicode_version":"11.0"}] diff --git a/web/src/app/errors.js b/web/src/app/errors.js index 28f49af..38165a2 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -1,80 +1,66 @@ -/* eslint-disable max-classes-per-file */ // This is a subset of, and the counterpart to errors.go -const maybeToJson = async (response) => { - try { - return await response.json(); - } catch (e) { - return null; - } +export const fetchOrThrow = async (url, options) => { + const response = await fetch(url, options); + if (response.status !== 200) { + await throwAppError(response); + } + return response; // Promise! }; +export const throwAppError = async (response) => { + if (response.status === 401 || response.status === 403) { + console.log(`[Error] HTTP ${response.status}`, response); + throw new UnauthorizedError(); + } + const error = await maybeToJson(response); + if (error?.code) { + console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); + if (error.code === UserExistsError.CODE) { + throw new UserExistsError(); + } else if (error.code === TopicReservedError.CODE) { + throw new TopicReservedError(); + } else if (error.code === AccountCreateLimitReachedError.CODE) { + throw new AccountCreateLimitReachedError(); + } else if (error.code === IncorrectPasswordError.CODE) { + throw new IncorrectPasswordError(); + } else if (error?.error) { + throw new Error(`Error ${error.code}: ${error.error}`); + } + } + console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response); + throw new Error(`Unexpected response ${response.status}`); +}; + +const maybeToJson = async (response) => { + try { + return await response.json(); + } catch (e) { + return null; + } +} + export class UnauthorizedError extends Error { - constructor() { - super("Unauthorized"); - } + constructor() { super("Unauthorized"); } } export class UserExistsError extends Error { - static CODE = 40901; // errHTTPConflictUserExists - - constructor() { - super("Username already exists"); - } + static CODE = 40901; // errHTTPConflictUserExists + constructor() { super("Username already exists"); } } export class TopicReservedError extends Error { - static CODE = 40902; // errHTTPConflictTopicReserved - - constructor() { - super("Topic already reserved"); - } + static CODE = 40902; // errHTTPConflictTopicReserved + constructor() { super("Topic already reserved"); } } export class AccountCreateLimitReachedError extends Error { - static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation - - constructor() { - super("Account creation limit reached"); - } + static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation + constructor() { super("Account creation limit reached"); } } export class IncorrectPasswordError extends Error { - static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation - - constructor() { - super("Password incorrect"); - } + static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation + constructor() { super("Password incorrect"); } } -export const throwAppError = async (response) => { - if (response.status === 401 || response.status === 403) { - console.log(`[Error] HTTP ${response.status}`, response); - throw new UnauthorizedError(); - } - const error = await maybeToJson(response); - if (error?.code) { - console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); - if (error.code === UserExistsError.CODE) { - throw new UserExistsError(); - } else if (error.code === TopicReservedError.CODE) { - throw new TopicReservedError(); - } else if (error.code === AccountCreateLimitReachedError.CODE) { - throw new AccountCreateLimitReachedError(); - } else if (error.code === IncorrectPasswordError.CODE) { - throw new IncorrectPasswordError(); - } else if (error?.error) { - throw new Error(`Error ${error.code}: ${error.error}`); - } - } - console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response); - throw new Error(`Unexpected response ${response.status}`); -}; - -export const fetchOrThrow = async (url, options) => { - const response = await fetch(url, options); - if (response.status !== 200) { - await throwAppError(response); - } - return response; // Promise! -}; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index ab7551b..88f67ce 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -1,5 +1,4 @@ -import { Base64 } from "js-base64"; -import { rawEmojis } from "./emojis"; +import {rawEmojis} from "./emojis"; import beep from "../sounds/beep.mp3"; import juntos from "../sounds/juntos.mp3"; import pristine from "../sounds/pristine.mp3"; @@ -8,14 +7,12 @@ import dadum from "../sounds/dadum.mp3"; import pop from "../sounds/pop.mp3"; import popSwoosh from "../sounds/pop-swoosh.mp3"; import config from "./config"; +import {Base64} from 'js-base64'; -export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; -export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); -export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; -export const expandSecureUrl = (url) => `https://${url}`; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; -export const topicUrlWs = (baseUrl, topic) => - `${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://"); +export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws` + .replaceAll("https://", "wss://") + .replaceAll("http://", "ws://"); export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`; export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`; export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; @@ -30,261 +27,269 @@ export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reserva export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`; export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; -export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; -export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; +export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; +export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); +export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; +export const expandSecureUrl = (url) => `https://${url}`; -export const validUrl = (url) => url.match(/^https?:\/\/.+/); - -export const disallowedTopic = (topic) => config.disallowed_topics.includes(topic); +export const validUrl = (url) => { + return url.match(/^https?:\/\/.+/); +} export const validTopic = (topic) => { - if (disallowedTopic(topic)) { - return false; - } - return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app! -}; + if (disallowedTopic(topic)) { + return false; + } + return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app! +} + +export const disallowedTopic = (topic) => { + return config.disallowed_topics.includes(topic); +} export const topicDisplayName = (subscription) => { - if (subscription.displayName) { - return subscription.displayName; - } - if (subscription.baseUrl === config.base_url) { - return subscription.topic; - } - return topicShortUrl(subscription.baseUrl, subscription.topic); + if (subscription.displayName) { + return subscription.displayName; + } else if (subscription.baseUrl === config.base_url) { + return subscription.topic; + } + return topicShortUrl(subscription.baseUrl, subscription.topic); }; // Format emojis (see emoji.js) const emojis = {}; -rawEmojis.forEach((emoji) => { - emoji.aliases.forEach((alias) => { - emojis[alias] = emoji.emoji; - }); +rawEmojis.forEach(emoji => { + emoji.aliases.forEach(alias => { + emojis[alias] = emoji.emoji; + }); }); const toEmojis = (tags) => { - if (!tags) return []; - return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]); + if (!tags) return []; + else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]); +} + +export const formatTitleWithDefault = (m, fallback) => { + if (m.title) { + return formatTitle(m); + } + return fallback; }; export const formatTitle = (m) => { - const emojiList = toEmojis(m.tags); - if (emojiList.length > 0) { - return `${emojiList.join(" ")} ${m.title}`; - } - return m.title; -}; - -export const formatTitleWithDefault = (m, fallback) => { - if (m.title) { - return formatTitle(m); - } - return fallback; + const emojiList = toEmojis(m.tags); + if (emojiList.length > 0) { + return `${emojiList.join(" ")} ${m.title}`; + } else { + return m.title; + } }; export const formatMessage = (m) => { - if (m.title) { - return m.message; - } - const emojiList = toEmojis(m.tags); - if (emojiList.length > 0) { - return `${emojiList.join(" ")} ${m.message}`; - } - return m.message; + if (m.title) { + return m.message; + } else { + const emojiList = toEmojis(m.tags); + if (emojiList.length > 0) { + return `${emojiList.join(" ")} ${m.message}`; + } else { + return m.message; + } + } }; export const unmatchedTags = (tags) => { - if (!tags) return []; - return tags.filter((tag) => !(tag in emojis)); -}; - -export const encodeBase64 = (s) => Base64.encode(s); - -export const encodeBase64Url = (s) => Base64.encodeURI(s); - -export const bearerAuth = (token) => `Bearer ${token}`; - -export const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`; - -export const withBearerAuth = (headers, token) => ({ ...headers, Authorization: bearerAuth(token) }); - -export const maybeWithBearerAuth = (headers, token) => { - if (token) { - return withBearerAuth(headers, token); - } - return headers; -}; - -export const withBasicAuth = (headers, username, password) => ({ ...headers, Authorization: basicAuth(username, password) }); + if (!tags) return []; + else return tags.filter(tag => !(tag in emojis)); +} export const maybeWithAuth = (headers, user) => { - if (user?.password) { - return withBasicAuth(headers, user.username, user.password); - } - if (user?.token) { - return withBearerAuth(headers, user.token); - } - return headers; -}; + if (user && user.password) { + return withBasicAuth(headers, user.username, user.password); + } else if (user && user.token) { + return withBearerAuth(headers, user.token); + } + return headers; +} + +export const maybeWithBearerAuth = (headers, token) => { + if (token) { + return withBearerAuth(headers, token); + } + return headers; +} + +export const withBasicAuth = (headers, username, password) => { + headers['Authorization'] = basicAuth(username, password); + return headers; +} + +export const basicAuth = (username, password) => { + return `Basic ${encodeBase64(`${username}:${password}`)}`; +} + +export const withBearerAuth = (headers, token) => { + headers['Authorization'] = bearerAuth(token); + return headers; +} + +export const bearerAuth = (token) => { + return `Bearer ${token}`; +} + +export const encodeBase64 = (s) => { + return Base64.encode(s); +} + +export const encodeBase64Url = (s) => { + return Base64.encodeURI(s); +} export const maybeAppendActionErrors = (message, notification) => { - const actionErrors = (notification.actions ?? []) - .map((action) => action.error) - .filter((action) => !!action) - .join("\n"); - if (actionErrors.length === 0) { - return message; - } - return `${message}\n\n${actionErrors}`; -}; + const actionErrors = (notification.actions ?? []) + .map(action => action.error) + .filter(action => !!action) + .join("\n") + if (actionErrors.length === 0) { + return message; + } else { + return `${message}\n\n${actionErrors}`; + } +} export const shuffle = (arr) => { - const returnArr = [...arr]; + let j, x; + for (let index = arr.length - 1; index > 0; index--) { + j = Math.floor(Math.random() * (index + 1)); + x = arr[index]; + arr[index] = arr[j]; + arr[j] = x; + } + return arr; +} - for (let index = returnArr.length - 1; index > 0; index -= 1) { - const j = Math.floor(Math.random() * (index + 1)); - [returnArr[index], returnArr[j]] = [returnArr[j], returnArr[index]]; - } - - return returnArr; -}; - -export const splitNoEmpty = (s, delimiter) => - s - .split(delimiter) - .map((x) => x.trim()) - .filter((x) => x !== ""); +export const splitNoEmpty = (s, delimiter) => { + return s + .split(delimiter) + .map(x => x.trim()) + .filter(x => x !== ""); +} /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */ export const hashCode = async (s) => { - let hash = 0; - for (let i = 0; i < s.length; i += 1) { - const char = s.charCodeAt(i); - // eslint-disable-next-line no-bitwise - hash = (hash << 5) - hash + char; - // eslint-disable-next-line no-bitwise - hash &= hash; // Convert to 32bit integer - } - return hash; -}; + let hash = 0; + for (let i = 0; i < s.length; i++) { + const char = s.charCodeAt(i); + hash = ((hash<<5)-hash)+char; + hash = hash & hash; // Convert to 32bit integer + } + return hash; +} -export const formatShortDateTime = (timestamp) => - new Intl.DateTimeFormat("default", { - dateStyle: "short", - timeStyle: "short", - }).format(new Date(timestamp * 1000)); +export const formatShortDateTime = (timestamp) => { + return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'}) + .format(new Date(timestamp * 1000)); +} -export const formatShortDate = (timestamp) => new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000)); +export const formatShortDate = (timestamp) => { + return new Intl.DateTimeFormat('default', {dateStyle: 'short'}) + .format(new Date(timestamp * 1000)); +} export const formatBytes = (bytes, decimals = 2) => { - if (bytes === 0) return "0 bytes"; - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; -}; + if (bytes === 0) return '0 bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} export const formatNumber = (n) => { - if (n === 0) { + if (n % 1000 === 0) { + return `${n/1000}k`; + } return n; - } - if (n % 1000 === 0) { - return `${n / 1000}k`; - } - return n.toLocaleString(); -}; - -export const formatPrice = (n) => { - if (n % 100 === 0) { - return `$${n / 100}`; - } - return `$${(n / 100).toPrecision(2)}`; -}; +} export const openUrl = (url) => { - window.open(url, "_blank", "noopener,noreferrer"); + window.open(url, "_blank", "noopener,noreferrer"); }; export const sounds = { - ding: { - file: ding, - label: "Ding", - }, - juntos: { - file: juntos, - label: "Juntos", - }, - pristine: { - file: pristine, - label: "Pristine", - }, - dadum: { - file: dadum, - label: "Dadum", - }, - pop: { - file: pop, - label: "Pop", - }, - "pop-swoosh": { - file: popSwoosh, - label: "Pop swoosh", - }, - beep: { - file: beep, - label: "Beep", - }, + "ding": { + file: ding, + label: "Ding" + }, + "juntos": { + file: juntos, + label: "Juntos" + }, + "pristine": { + file: pristine, + label: "Pristine" + }, + "dadum": { + file: dadum, + label: "Dadum" + }, + "pop": { + file: pop, + label: "Pop" + }, + "pop-swoosh": { + file: popSwoosh, + label: "Pop swoosh" + }, + "beep": { + file: beep, + label: "Beep" + } }; export const playSound = async (id) => { - const audio = new Audio(sounds[id].file); - return audio.play(); + const audio = new Audio(sounds[id].file); + return audio.play(); }; // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch -// eslint-disable-next-line func-style export async function* fetchLinesIterator(fileURL, headers) { - const utf8Decoder = new TextDecoder("utf-8"); - const response = await fetch(fileURL, { - headers, - }); - const reader = response.body.getReader(); - let { value: chunk, done: readerDone } = await reader.read(); - chunk = chunk ? utf8Decoder.decode(chunk) : ""; + const utf8Decoder = new TextDecoder('utf-8'); + const response = await fetch(fileURL, { + headers: headers + }); + const reader = response.body.getReader(); + let { value: chunk, done: readerDone } = await reader.read(); + chunk = chunk ? utf8Decoder.decode(chunk) : ''; - const re = /\n|\r|\r\n/gm; - let startIndex = 0; + const re = /\n|\r|\r\n/gm; + let startIndex = 0; - for (;;) { - const result = re.exec(chunk); - if (!result) { - if (readerDone) { - break; - } - const remainder = chunk.substr(startIndex); - // eslint-disable-next-line no-await-in-loop - ({ value: chunk, done: readerDone } = await reader.read()); - chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ""); - startIndex = 0; - re.lastIndex = 0; - // eslint-disable-next-line no-continue - continue; + for (;;) { + let result = re.exec(chunk); + if (!result) { + if (readerDone) { + break; + } + let remainder = chunk.substr(startIndex); + ({ value: chunk, done: readerDone } = await reader.read()); + chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ''); + startIndex = re.lastIndex = 0; + continue; + } + yield chunk.substring(startIndex, result.index); + startIndex = re.lastIndex; + } + if (startIndex < chunk.length) { + yield chunk.substr(startIndex); // last line didn't end in a newline char } - yield chunk.substring(startIndex, result.index); - startIndex = re.lastIndex; - } - if (startIndex < chunk.length) { - yield chunk.substr(startIndex); // last line didn't end in a newline char - } } export const randomAlphanumericString = (len) => { - const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let id = ""; - for (let i = 0; i < len; i += 1) { - // eslint-disable-next-line no-bitwise - id += alphabet[(Math.random() * alphabet.length) | 0]; - } - return id; -}; + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let id = ""; + for (let i = 0; i < len; i++) { + id += alphabet[(Math.random() * alphabet.length) | 0]; + } + return id; +} diff --git a/web/src/components/Account.js b/web/src/components/Account.js new file mode 100644 index 0000000..224999b --- /dev/null +++ b/web/src/components/Account.js @@ -0,0 +1,798 @@ +import * as React from 'react'; +import {useContext, useState} from 'react'; +import { + Alert, + CardActions, + CardContent, + FormControl, + LinearProgress, + Link, + Portal, + Select, + Snackbar, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + useMediaQuery +} from "@mui/material"; +import Tooltip from '@mui/material/Tooltip'; +import Typography from "@mui/material/Typography"; +import EditIcon from '@mui/icons-material/Edit'; +import Container from "@mui/material/Container"; +import Card from "@mui/material/Card"; +import Button from "@mui/material/Button"; +import {Trans, useTranslation} from "react-i18next"; +import session from "../app/Session"; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import theme from "./theme"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import TextField from "@mui/material/TextField"; +import routes from "./routes"; +import IconButton from "@mui/material/IconButton"; +import {formatBytes, formatShortDate, formatShortDateTime, openUrl} from "../app/utils"; +import accountApi, {LimitBasis, Role, SubscriptionStatus} from "../app/AccountApi"; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import {Pref, PrefGroup} from "./Pref"; +import db from "../app/db"; +import i18n from "i18next"; +import humanizeDuration from "humanize-duration"; +import UpgradeDialog from "./UpgradeDialog"; +import CelebrationIcon from "@mui/icons-material/Celebration"; +import {AccountContext} from "./App"; +import DialogFooter from "./DialogFooter"; +import {Paragraph} from "./styles"; +import CloseIcon from "@mui/icons-material/Close"; +import {ContentCopy, Public} from "@mui/icons-material"; +import MenuItem from "@mui/material/MenuItem"; +import DialogContentText from "@mui/material/DialogContentText"; +import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; + +const Account = () => { + if (!session.exists()) { + window.location.href = routes.app; + return <>; + } + return ( + + + + + + + + + ); +}; + +const Basics = () => { + const { t } = useTranslation(); + return ( + + + {t("account_basics_title")} + + + + + + + + ); +}; + +const Username = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const labelId = "prefUsername"; + + return ( + +
+ {session.username()} + {account?.role === Role.ADMIN + ? <>{" "}👑 + : ""} +
+
+ ) +}; + +const ChangePassword = () => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const labelId = "prefChangePassword"; + + const handleDialogOpen = () => { + setDialogKey(prev => prev+1); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + return ( + +
+ ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤ + + + +
+ +
+ ) +}; + +const ChangePasswordDialog = (props) => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const handleDialogSubmit = async () => { + try { + console.debug(`[Account] Changing password`); + await accountApi.changePassword(currentPassword, newPassword); + props.onClose(); + } catch (e) { + console.log(`[Account] Error changing password`, e); + if (e instanceof IncorrectPasswordError) { + setError(t("account_basics_password_dialog_current_password_incorrect")); + } else if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; + + return ( + + {t("account_basics_password_dialog_title")} + + setCurrentPassword(ev.target.value)} + fullWidth + variant="standard" + /> + setNewPassword(ev.target.value)} + fullWidth + variant="standard" + /> + setConfirmPassword(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + ); +}; + +const AccountType = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [upgradeDialogKey, setUpgradeDialogKey] = useState(0); + const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + const [showPortalError, setShowPortalError] = useState(false); + + if (!account) { + return <>; + } + + const handleUpgradeClick = () => { + setUpgradeDialogKey(k => k + 1); + setUpgradeDialogOpen(true); + } + + const handleManageBilling = async () => { + try { + const response = await accountApi.createBillingPortalSession(); + window.open(response.redirect_url, "billing_portal"); + } catch (e) { + console.log(`[Account] Error opening billing portal`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setShowPortalError(true); + } + } + }; + + let accountType; + if (account.role === Role.ADMIN) { + const tierSuffix = (account.tier) ? t("account_basics_tier_admin_suffix_with_tier", { tier: account.tier.name }) : t("account_basics_tier_admin_suffix_no_tier"); + accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`; + } else if (!account.tier) { + accountType = (config.enable_payments) ? t("account_basics_tier_free") : t("account_basics_tier_basic"); + } else { + accountType = account.tier.name; + } + + return ( + 0} + title={t("account_basics_tier_title")} + description={t("account_basics_tier_description")} + > +
+ {accountType} + {account.billing?.paid_until && !account.billing?.cancel_at && + + + + } + {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && + + } + {config.enable_payments && account.role === Role.USER && account.billing?.subscription && + + } + {config.enable_payments && account.role === Role.USER && account.billing?.customer && + + } + {config.enable_payments && + setUpgradeDialogOpen(false)} + /> + } +
+ {account.billing?.status === SubscriptionStatus.PAST_DUE && + {t("account_basics_tier_payment_overdue")} + } + {account.billing?.cancel_at > 0 && + {t("account_basics_tier_canceled_subscription", { date: formatShortDate(account.billing.cancel_at) })} + } + + setShowPortalError(false)} + message={t("account_usage_cannot_create_portal_session")} + /> + +
+ ) +}; + +const Stats = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + + if (!account) { + return <>; + } + + const normalize = (value, max) => { + return Math.min(value / max * 100, 100); + }; + + return ( + + + {t("account_usage_title")} + + + + {(account.role === Role.ADMIN || account.limits.reservations > 0) && + <> +
+ {account.stats.reservations} + {account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} + /> + + } + {account.role === Role.USER && account.limits.reservations === 0 && + {t("account_usage_reservations_none")} + } +
+ + {t("account_usage_messages_title")} + + + }> +
+ {account.stats.messages} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")} +
+ +
+ + {t("account_usage_emails_title")} + + + }> +
+ {account.stats.emails} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")} +
+ +
+ +
+ {formatBytes(account.stats.attachment_total_size)} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")} +
+ +
+
+ {account.role === Role.USER && account.limits.basis === LimitBasis.IP && + + {t("account_usage_basis_ip_description")} + + } +
+ ); +}; + +const InfoIcon = () => { + return ( + + ); +} + + +const Tokens = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const tokens = account?.tokens || []; + + const handleCreateClick = () => { + setDialogKey(prev => prev+1); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + const handleDialogSubmit = async (user) => { + setDialogOpen(false); + // + }; + return ( + + + + {t("account_tokens_title")} + + + + }} + /> + + {tokens?.length > 0 && } + + + + + + + ); +}; + +const TokensTable = (props) => { + const { t } = useTranslation(); + const [snackOpen, setSnackOpen] = useState(false); + const [upsertDialogKey, setUpsertDialogKey] = useState(0); + const [upsertDialogOpen, setUpsertDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedToken, setSelectedToken] = useState(null); + + const tokens = (props.tokens || []) + .sort( (a, b) => { + if (a.token === session.token()) { + return -1; + } else if (b.token === session.token()) { + return 1; + } + return a.token.localeCompare(b.token); + }); + + const handleEditClick = (token) => { + setUpsertDialogKey(prev => prev+1); + setSelectedToken(token); + setUpsertDialogOpen(true); + }; + + const handleDialogClose = () => { + setUpsertDialogOpen(false); + setDeleteDialogOpen(false); + setSelectedToken(null); + }; + + const handleDeleteClick = async (token) => { + setSelectedToken(token); + setDeleteDialogOpen(true); + }; + + const handleCopy = async (token) => { + await navigator.clipboard.writeText(token); + setSnackOpen(true); + }; + + return ( +
TagEmoji
" >> "$1" cat "$SCRIPTDIR/emoji.json" \ - | jq -r '.[] | ""' \ + | jq -r '.[] | ""' \ | sed -n "${from},${to}p" >> "$1" echo "
TagEmoji
" + .aliases[0] + "" + .emoji + "
" + .aliases[0] + "" + .emoji + "
+ + + {t("account_tokens_table_token_header")} + {t("account_tokens_table_label_header")} + {t("account_tokens_table_expires_header")} + {t("account_tokens_table_last_access_header")} + + + + + {tokens.map(token => ( + + + + {token.token.slice(0, 12)} + ... + + handleCopy(token.token)}> + + + + + {token.token === session.token() && {t("account_tokens_table_current_session")}} + {token.token !== session.token() && (token.label || "-")} + + + {token.expires ? formatShortDateTime(token.expires) : {t("account_tokens_table_never_expires")}} + + +
+ {formatShortDateTime(token.last_access)} + + openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}> + + + +
+
+ + {token.token !== session.token() && + <> + handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> + + + handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> + + + + } + {token.token === session.token() && + + + + + + + } + +
+ ))} +
+ + setSnackOpen(false)} + message={t("account_tokens_table_copied_to_clipboard")} + /> + + + +
+ ); +}; + +const TokenDialog = (props) => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [label, setLabel] = useState(props.token?.label || ""); + const [expires, setExpires] = useState(props.token ? -1 : 0); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const editMode = !!props.token; + + const handleSubmit = async () => { + try { + if (editMode) { + await accountApi.updateToken(props.token.token, label, expires); + } else { + await accountApi.createToken(label, expires); + } + props.onClose(); + } catch (e) { + console.log(`[Account] Error creating token`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; + + return ( + + {editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")} + + setLabel(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + + + + ); +}; + +const TokenDeleteDialog = (props) => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + + const handleSubmit = async () => { + try { + await accountApi.deleteToken(props.token.token); + props.onClose(); + } catch (e) { + console.log(`[Account] Error deleting token`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; + + return ( + + {t("account_tokens_delete_dialog_title")} + + + + + + + + + + + ); +} + + +const Delete = () => { + const { t } = useTranslation(); + return ( + + + {t("account_delete_title")} + + + + + + ); +}; + +const DeleteAccount = () => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + + const handleDialogOpen = () => { + setDialogKey(prev => prev+1); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + return ( + +
+ +
+ +
+ ) +}; + +const DeleteAccountDialog = (props) => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [error, setError] = useState(""); + const [password, setPassword] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const handleSubmit = async () => { + try { + await accountApi.delete(password); + await db.delete(); + console.debug(`[Account] Account deleted`); + session.resetAndRedirect(routes.app); + } catch (e) { + console.log(`[Account] Error deleting account`, e); + if (e instanceof IncorrectPasswordError) { + setError(t("account_basics_password_dialog_current_password_incorrect")); + } else if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; + + return ( + + {t("account_delete_title")} + + + {t("account_delete_dialog_description")} + + setPassword(ev.target.value)} + fullWidth + variant="standard" + /> + {account?.billing?.subscription && + {t("account_delete_dialog_billing_warning")} + } + + + + + + + ); +}; + +export default Account; diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx deleted file mode 100644 index 541d4f8..0000000 --- a/web/src/components/Account.jsx +++ /dev/null @@ -1,1128 +0,0 @@ -import * as React from "react"; -import { useContext, useState } from "react"; -import { - Alert, - CardActions, - CardContent, - Chip, - FormControl, - FormControlLabel, - LinearProgress, - Link, - Portal, - Radio, - RadioGroup, - Select, - Snackbar, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - useMediaQuery, - Tooltip, - Typography, - Container, - Card, - Button, - Dialog, - DialogTitle, - DialogContent, - TextField, - IconButton, - MenuItem, - DialogContentText, -} from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; -import { Trans, useTranslation } from "react-i18next"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import i18n from "i18next"; -import humanizeDuration from "humanize-duration"; -import CelebrationIcon from "@mui/icons-material/Celebration"; -import CloseIcon from "@mui/icons-material/Close"; -import { ContentCopy, Public } from "@mui/icons-material"; -import AddIcon from "@mui/icons-material/Add"; -import routes from "./routes"; -import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils"; -import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi"; -import { Pref, PrefGroup } from "./Pref"; -import db from "../app/db"; -import UpgradeDialog from "./UpgradeDialog"; -import { AccountContext } from "./App"; -import DialogFooter from "./DialogFooter"; -import { Paragraph } from "./styles"; -import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; -import { ProChip } from "./SubscriptionPopup"; -import theme from "./theme"; -import session from "../app/Session"; - -const Account = () => { - if (!session.exists()) { - window.location.href = routes.app; - return <>; - } - return ( - - - - - - - - - ); -}; - -const Basics = () => { - const { t } = useTranslation(); - return ( - - - {t("account_basics_title")} - - - - - - - - - ); -}; - -const Username = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const labelId = "prefUsername"; - - return ( - -
- {session.username()} - {account?.role === Role.ADMIN ? ( - <> - {" "} - - 👑 - - - ) : ( - "" - )} -
-
- ); -}; - -const ChangePassword = () => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const labelId = "prefChangePassword"; - - const handleDialogOpen = () => { - setDialogKey((prev) => prev + 1); - setDialogOpen(true); - }; - - const handleDialogClose = () => { - setDialogOpen(false); - }; - - return ( - -
- - ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤ - - - - -
- -
- ); -}; - -const ChangePasswordDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - - const handleDialogSubmit = async () => { - try { - console.debug(`[Account] Changing password`); - await accountApi.changePassword(currentPassword, newPassword); - props.onClose(); - } catch (e) { - console.log(`[Account] Error changing password`, e); - if (e instanceof IncorrectPasswordError) { - setError(t("account_basics_password_dialog_current_password_incorrect")); - } else if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } - }; - - return ( - - {t("account_basics_password_dialog_title")} - - setCurrentPassword(ev.target.value)} - fullWidth - variant="standard" - /> - setNewPassword(ev.target.value)} - fullWidth - variant="standard" - /> - setConfirmPassword(ev.target.value)} - fullWidth - variant="standard" - /> - - - - - - - ); -}; - -const AccountType = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [upgradeDialogKey, setUpgradeDialogKey] = useState(0); - const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); - const [showPortalError, setShowPortalError] = useState(false); - - if (!account) { - return <>; - } - - const handleUpgradeClick = () => { - setUpgradeDialogKey((k) => k + 1); - setUpgradeDialogOpen(true); - }; - - const handleManageBilling = async () => { - try { - const response = await accountApi.createBillingPortalSession(); - window.open(response.redirect_url, "billing_portal"); - } catch (e) { - console.log(`[Account] Error opening billing portal`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setShowPortalError(true); - } - } - }; - - let accountType; - if (account.role === Role.ADMIN) { - const tierSuffix = account.tier - ? t("account_basics_tier_admin_suffix_with_tier", { - tier: account.tier.name, - }) - : t("account_basics_tier_admin_suffix_no_tier"); - accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`; - } else if (!account.tier) { - accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic"); - } else { - accountType = account.tier.name; - if (account.billing?.interval === SubscriptionInterval.MONTH) { - accountType += ` (${t("account_basics_tier_interval_monthly")})`; - } else if (account.billing?.interval === SubscriptionInterval.YEAR) { - accountType += ` (${t("account_basics_tier_interval_yearly")})`; - } - } - - return ( - 0} - title={t("account_basics_tier_title")} - description={t("account_basics_tier_description")} - > -
- {accountType} - {account.billing?.paid_until && !account.billing?.cancel_at && ( - - - - - - )} - {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && ( - - )} - {config.enable_payments && account.role === Role.USER && account.billing?.subscription && ( - - )} - {config.enable_payments && account.role === Role.USER && account.billing?.customer && ( - - )} - {config.enable_payments && ( - setUpgradeDialogOpen(false)} - /> - )} -
- {account.billing?.status === SubscriptionStatus.PAST_DUE && ( - - {t("account_basics_tier_payment_overdue")} - - )} - {account.billing?.cancel_at > 0 && ( - - {t("account_basics_tier_canceled_subscription", { - date: formatShortDate(account.billing.cancel_at), - })} - - )} - - setShowPortalError(false)} - message={t("account_usage_cannot_create_portal_session")} - /> - -
- ); -}; - -const PhoneNumbers = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const [snackOpen, setSnackOpen] = useState(false); - const labelId = "prefPhoneNumbers"; - - const handleDialogOpen = () => { - setDialogKey((prev) => prev + 1); - setDialogOpen(true); - }; - - const handleDialogClose = () => { - setDialogOpen(false); - }; - - const handleCopy = (phoneNumber) => { - navigator.clipboard.writeText(phoneNumber); - setSnackOpen(true); - }; - - const handleDelete = async (phoneNumber) => { - try { - await accountApi.deletePhoneNumber(phoneNumber); - } catch (e) { - console.log(`[Account] Error deleting phone number`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } - }; - - if (!config.enable_calls) { - return null; - } - - if (account?.limits.calls === 0) { - return ( - - {t("account_basics_phone_numbers_title")} - {config.enable_payments && } - - } - description={t("account_basics_phone_numbers_description")} - > - {t("account_usage_calls_none")} - - ); - } - - return ( - -
- {account?.phone_numbers?.map((phoneNumber) => ( - - {phoneNumber} - - } - variant="outlined" - onClick={() => handleCopy(phoneNumber)} - onDelete={() => handleDelete(phoneNumber)} - /> - ))} - {!account?.phone_numbers && {t("account_basics_phone_numbers_no_phone_numbers_yet")}} - - - -
- - - setSnackOpen(false)} - message={t("account_basics_phone_numbers_copied_to_clipboard")} - /> - -
- ); -}; - -const AddPhoneNumberDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [phoneNumber, setPhoneNumber] = useState(""); - const [channel, setChannel] = useState("sms"); - const [code, setCode] = useState(""); - const [sending, setSending] = useState(false); - const [verificationCodeSent, setVerificationCodeSent] = useState(false); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - - const verifyPhone = async () => { - try { - setSending(true); - await accountApi.verifyPhoneNumber(phoneNumber, channel); - setVerificationCodeSent(true); - } catch (e) { - console.log(`[Account] Error sending verification`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } finally { - setSending(false); - } - }; - - const checkVerifyPhone = async () => { - try { - setSending(true); - await accountApi.addPhoneNumber(phoneNumber, code); - props.onClose(); - } catch (e) { - console.log(`[Account] Error confirming verification`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } finally { - setSending(false); - } - }; - - const handleDialogSubmit = async () => { - if (!verificationCodeSent) { - await verifyPhone(); - } else { - await checkVerifyPhone(); - } - }; - - const handleCancel = () => { - if (verificationCodeSent) { - setVerificationCodeSent(false); - setCode(""); - } else { - props.onClose(); - } - }; - - return ( - - {t("account_basics_phone_numbers_dialog_title")} - - {t("account_basics_phone_numbers_dialog_description")} - {!verificationCodeSent && ( -
- setPhoneNumber(ev.target.value)} - inputProps={{ inputMode: "tel", pattern: "+[0-9]*" }} - variant="standard" - sx={{ flexGrow: 1 }} - /> - - - setChannel(e.target.value)} />} - label={t("account_basics_phone_numbers_dialog_channel_sms")} - /> - setChannel(e.target.value)} />} - label={t("account_basics_phone_numbers_dialog_channel_call")} - sx={{ marginRight: 0 }} - /> - - -
- )} - {verificationCodeSent && ( - setCode(ev.target.value)} - fullWidth - inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} - variant="standard" - /> - )} -
- - - - -
- ); -}; - -const Stats = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - - if (!account) { - return <>; - } - - const normalize = (value, max) => Math.min((value / max) * 100, 100); - - return ( - - - {t("account_usage_title")} - - - {(account.role === Role.ADMIN || account.limits.reservations > 0) && ( - -
- - {account.stats.reservations.toLocaleString()} - - - {account.role === Role.USER - ? t("account_usage_of_limit", { - limit: account.limits.reservations.toLocaleString(), - }) - : t("account_usage_unlimited")} - -
- 0 - ? normalize(account.stats.reservations, account.limits.reservations) - : 100 - } - /> -
- )} - - {t("account_usage_messages_title")} - - - - - - - } - > -
- - {account.stats.messages.toLocaleString()} - - - {account.role === Role.USER - ? t("account_usage_of_limit", { - limit: account.limits.messages.toLocaleString(), - }) - : t("account_usage_unlimited")} - -
- -
- {config.enable_emails && ( - - {t("account_usage_emails_title")} - - - - - - - } - > -
- - {account.stats.emails.toLocaleString()} - - - {account.role === Role.USER - ? t("account_usage_of_limit", { - limit: account.limits.emails.toLocaleString(), - }) - : t("account_usage_unlimited")} - -
- -
- )} - {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && ( - - {t("account_usage_calls_title")} - - - - - - - } - > -
- - {account.stats.calls.toLocaleString()} - - - {account.role === Role.USER - ? t("account_usage_of_limit", { - limit: account.limits.calls.toLocaleString(), - }) - : t("account_usage_unlimited")} - -
- 0 ? normalize(account.stats.calls, account.limits.calls) : 100} - /> -
- )} - -
- - {formatBytes(account.stats.attachment_total_size)} - - - {account.role === Role.USER - ? t("account_usage_of_limit", { - limit: formatBytes(account.limits.attachment_total_size), - }) - : t("account_usage_unlimited")} - -
- -
- {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && ( - - {t("account_usage_reservations_title")} - {config.enable_payments && } - - } - > - {t("account_usage_reservations_none")} - - )} - {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && ( - - {t("account_usage_calls_title")} - {config.enable_payments && } - - } - > - {t("account_usage_calls_none")} - - )} -
- {account.role === Role.USER && account.limits.basis === LimitBasis.IP && ( - {t("account_usage_basis_ip_description")} - )} -
- ); -}; - -const InfoIcon = () => ( - -); - -const Tokens = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const tokens = account?.tokens || []; - - const handleCreateClick = () => { - setDialogKey((prev) => prev + 1); - setDialogOpen(true); - }; - - const handleDialogClose = () => { - setDialogOpen(false); - }; - - return ( - - - - {t("account_tokens_title")} - - - , - }} - /> - - {tokens?.length > 0 && } - - - - - - - ); -}; - -const TokensTable = (props) => { - const { t } = useTranslation(); - const [snackOpen, setSnackOpen] = useState(false); - const [upsertDialogKey, setUpsertDialogKey] = useState(0); - const [upsertDialogOpen, setUpsertDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [selectedToken, setSelectedToken] = useState(null); - - const tokens = (props.tokens || []).sort((a, b) => { - if (a.token === session.token()) { - return -1; - } - if (b.token === session.token()) { - return 1; - } - return a.token.localeCompare(b.token); - }); - - const handleEditClick = (token) => { - setUpsertDialogKey((prev) => prev + 1); - setSelectedToken(token); - setUpsertDialogOpen(true); - }; - - const handleDialogClose = () => { - setUpsertDialogOpen(false); - setDeleteDialogOpen(false); - setSelectedToken(null); - }; - - const handleDeleteClick = async (token) => { - setSelectedToken(token); - setDeleteDialogOpen(true); - }; - - const handleCopy = async (token) => { - await navigator.clipboard.writeText(token); - setSnackOpen(true); - }; - - return ( - - - - {t("account_tokens_table_token_header")} - {t("account_tokens_table_label_header")} - {t("account_tokens_table_expires_header")} - {t("account_tokens_table_last_access_header")} - - - - - {tokens.map((token) => ( - - - - {token.token.slice(0, 12)} - ... - - handleCopy(token.token)}> - - - - - - - {token.token === session.token() && {t("account_tokens_table_current_session")}} - {token.token !== session.token() && (token.label || "-")} - - - {token.expires ? formatShortDateTime(token.expires) : {t("account_tokens_table_never_expires")}} - - -
- {formatShortDateTime(token.last_access)} - - openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}> - - - -
-
- - {token.token !== session.token() && ( - <> - handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> - - - handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> - - - - )} - {token.token === session.token() && ( - - - - - - - - - - - )} - -
- ))} -
- - setSnackOpen(false)} - message={t("account_tokens_table_copied_to_clipboard")} - /> - - - -
- ); -}; - -const TokenDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [label, setLabel] = useState(props.token?.label || ""); - const [expires, setExpires] = useState(props.token ? -1 : 0); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const editMode = !!props.token; - - const handleSubmit = async () => { - try { - if (editMode) { - await accountApi.updateToken(props.token.token, label, expires); - } else { - await accountApi.createToken(label, expires); - } - props.onClose(); - } catch (e) { - console.log(`[Account] Error creating token`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } - }; - - return ( - - {editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")} - - setLabel(ev.target.value)} - fullWidth - variant="standard" - /> - - - - - - - - - - ); -}; - -const TokenDeleteDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - - const handleSubmit = async () => { - try { - await accountApi.deleteToken(props.token.token); - props.onClose(); - } catch (e) { - console.log(`[Account] Error deleting token`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } - }; - - return ( - - {t("account_tokens_delete_dialog_title")} - - - - - - - - - - - ); -}; - -const Delete = () => { - const { t } = useTranslation(); - return ( - - - {t("account_delete_title")} - - - - - - ); -}; - -const DeleteAccount = () => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - - const handleDialogOpen = () => { - setDialogKey((prev) => prev + 1); - setDialogOpen(true); - }; - - const handleDialogClose = () => { - setDialogOpen(false); - }; - - return ( - -
- -
- -
- ); -}; - -const DeleteAccountDialog = (props) => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [error, setError] = useState(""); - const [password, setPassword] = useState(""); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - - const handleSubmit = async () => { - try { - await accountApi.delete(password); - await db.delete(); - console.debug(`[Account] Account deleted`); - session.resetAndRedirect(routes.app); - } catch (e) { - console.log(`[Account] Error deleting account`, e); - if (e instanceof IncorrectPasswordError) { - setError(t("account_basics_password_dialog_current_password_incorrect")); - } else if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } - }; - - return ( - - {t("account_delete_title")} - - {t("account_delete_dialog_description")} - setPassword(ev.target.value)} - fullWidth - variant="standard" - /> - {account?.billing?.subscription && ( - - {t("account_delete_dialog_billing_warning")} - - )} - - - - - - - ); -}; - -export default Account; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js new file mode 100644 index 0000000..189ae1c --- /dev/null +++ b/web/src/components/ActionBar.js @@ -0,0 +1,183 @@ +import AppBar from "@mui/material/AppBar"; +import Navigation from "./Navigation"; +import Toolbar from "@mui/material/Toolbar"; +import IconButton from "@mui/material/IconButton"; +import MenuIcon from "@mui/icons-material/Menu"; +import Typography from "@mui/material/Typography"; +import * as React from "react"; +import {useState} from "react"; +import Box from "@mui/material/Box"; +import {topicDisplayName} from "../app/utils"; +import db from "../app/db"; +import {useLocation, useNavigate} from "react-router-dom"; +import MenuItem from '@mui/material/MenuItem'; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; +import routes from "./routes"; +import subscriptionManager from "../app/SubscriptionManager"; +import logo from "../img/ntfy.svg"; +import {useTranslation} from "react-i18next"; +import session from "../app/Session"; +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import Button from "@mui/material/Button"; +import Divider from "@mui/material/Divider"; +import {Logout, Person, Settings} from "@mui/icons-material"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import accountApi from "../app/AccountApi"; +import PopupMenu from "./PopupMenu"; +import { SubscriptionPopup } from "./SubscriptionPopup"; + +const ActionBar = (props) => { + const { t } = useTranslation(); + const location = useLocation(); + let title = "ntfy"; + if (props.selected) { + title = topicDisplayName(props.selected); + } else if (location.pathname === routes.settings) { + title = t("action_bar_settings"); + } else if (location.pathname === routes.account) { + title = t("action_bar_account"); + } + return ( + Navigation (1200), but < Dialog (1300) + ml: { sm: `${Navigation.width}px` } + }}> + + + + + + + {title} + + {props.selected && + } + + + + ); +}; + +const SettingsIcons = (props) => { + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const subscription = props.subscription; + + const handleToggleMute = async () => { + const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future + await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); + } + + return ( + <> + + {subscription.mutedUntil ? : } + + setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}> + + + setAnchorEl(null)} + /> + + ); +}; + +const ProfileIcon = () => { + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const navigate = useNavigate(); + + const handleClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleLogout = async () => { + try { + await accountApi.logout(); + await db.delete(); + } finally { + session.resetAndRedirect(routes.app); + } + }; + + return ( + <> + {session.exists() && + + + + } + {!session.exists() && config.enable_login && + + } + {!session.exists() && config.enable_signup && + + } + + navigate(routes.account)}> + + + + {session.username()} + + + navigate(routes.settings)}> + + + + {t("action_bar_profile_settings")} + + + + + + {t("action_bar_profile_logout")} + + + + ); +}; + +export default ActionBar; diff --git a/web/src/components/ActionBar.jsx b/web/src/components/ActionBar.jsx deleted file mode 100644 index 798efb4..0000000 --- a/web/src/components/ActionBar.jsx +++ /dev/null @@ -1,172 +0,0 @@ -import { AppBar, Toolbar, IconButton, Typography, Box, MenuItem, Button, Divider, ListItemIcon } from "@mui/material"; -import MenuIcon from "@mui/icons-material/Menu"; -import * as React from "react"; -import { useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; -import MoreVertIcon from "@mui/icons-material/MoreVert"; -import NotificationsIcon from "@mui/icons-material/Notifications"; -import NotificationsOffIcon from "@mui/icons-material/NotificationsOff"; -import { useTranslation } from "react-i18next"; -import AccountCircleIcon from "@mui/icons-material/AccountCircle"; -import { Logout, Person, Settings } from "@mui/icons-material"; -import session from "../app/Session"; -import logo from "../img/ntfy.svg"; -import subscriptionManager from "../app/SubscriptionManager"; -import routes from "./routes"; -import db from "../app/db"; -import { topicDisplayName } from "../app/utils"; -import Navigation from "./Navigation"; -import accountApi from "../app/AccountApi"; -import PopupMenu from "./PopupMenu"; -import { SubscriptionPopup } from "./SubscriptionPopup"; - -const ActionBar = (props) => { - const { t } = useTranslation(); - const location = useLocation(); - let title = "ntfy"; - if (props.selected) { - title = topicDisplayName(props.selected); - } else if (location.pathname === routes.settings) { - title = t("action_bar_settings"); - } else if (location.pathname === routes.account) { - title = t("action_bar_account"); - } - return ( - Navigation (1200), but < Dialog (1300) - ml: { sm: `${Navigation.width}px` }, - }} - > - - - - - - - {title} - - {props.selected && } - - - - ); -}; - -const SettingsIcons = (props) => { - const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); - const { subscription } = props; - - const handleToggleMute = async () => { - const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future - await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); - }; - - return ( - <> - - {subscription.mutedUntil ? : } - - setAnchorEl(ev.currentTarget)} - aria-label={t("action_bar_toggle_action_menu")} - > - - - setAnchorEl(null)} /> - - ); -}; - -const ProfileIcon = () => { - const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - const navigate = useNavigate(); - - const handleClick = (event) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - const handleLogout = async () => { - try { - await accountApi.logout(); - await db.delete(); - } finally { - session.resetAndRedirect(routes.app); - } - }; - - return ( - <> - {session.exists() && ( - - - - )} - {!session.exists() && config.enable_login && ( - - )} - {!session.exists() && config.enable_signup && ( - - )} - - navigate(routes.account)}> - - - - {session.username()} - - - navigate(routes.settings)}> - - - - {t("action_bar_profile_settings")} - - - - - - {t("action_bar_profile_logout")} - - - - ); -}; - -export default ActionBar; diff --git a/web/src/components/App.js b/web/src/components/App.js new file mode 100644 index 0000000..861a370 --- /dev/null +++ b/web/src/components/App.js @@ -0,0 +1,147 @@ +import * as React from 'react'; +import {createContext, Suspense, useContext, useEffect, useState} from 'react'; +import Box from '@mui/material/Box'; +import {ThemeProvider} from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import Toolbar from '@mui/material/Toolbar'; +import {AllSubscriptions, SingleSubscription} from "./Notifications"; +import theme from "./theme"; +import Navigation from "./Navigation"; +import ActionBar from "./ActionBar"; +import notifier from "../app/Notifier"; +import Preferences from "./Preferences"; +import {useLiveQuery} from "dexie-react-hooks"; +import subscriptionManager from "../app/SubscriptionManager"; +import userManager from "../app/UserManager"; +import {BrowserRouter, Outlet, Route, Routes, useParams} from "react-router-dom"; +import {expandUrl} from "../app/utils"; +import ErrorBoundary from "./ErrorBoundary"; +import routes from "./routes"; +import {useAccountListener, useBackgroundProcesses, useConnectionListeners} from "./hooks"; +import PublishDialog from "./PublishDialog"; +import Messaging from "./Messaging"; +import "./i18n"; // Translations! +import {Backdrop, CircularProgress} from "@mui/material"; +import Login from "./Login"; +import Signup from "./Signup"; +import Account from "./Account"; + +export const AccountContext = createContext(null); + +const App = () => { + const [account, setAccount] = useState(null); + return ( + }> + + + + + + + }/> + }/> + }> + }/> + }/> + }/> + }/> + }/> + + + + + + + + ); +} + +const Layout = () => { + const params = useParams(); + const { account, setAccount } = useContext(AccountContext); + const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); + const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted()); + const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); + const users = useLiveQuery(() => userManager.all()); + const subscriptions = useLiveQuery(() => subscriptionManager.all()); + const subscriptionsWithoutInternal = subscriptions?.filter(s => !s.internal); + const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; + const [selected] = (subscriptionsWithoutInternal || []).filter(s => { + return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) + || (config.base_url === s.baseUrl && params.topic === s.topic) + }); + + useConnectionListeners(account, subscriptions, users); + useAccountListener(setAccount) + useBackgroundProcesses(); + useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); + + return ( + + setMobileDrawerOpen(!mobileDrawerOpen)} + /> + setMobileDrawerOpen(!mobileDrawerOpen)} + onNotificationGranted={setNotificationsGranted} + onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} + /> +
+ + +
+ +
+ ); +} + +const Main = (props) => { + return ( + theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] + }} + > + {props.children} + + ); +}; + +const Loader = () => ( + theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] + }} + > + + +); + +const updateTitle = (newNotificationsCount) => { + document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy"; +} + +export default App; diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx deleted file mode 100644 index 189235b..0000000 --- a/web/src/components/App.jsx +++ /dev/null @@ -1,140 +0,0 @@ -import * as React from "react"; -import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react"; -import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress } from "@mui/material"; -import { ThemeProvider } from "@mui/material/styles"; -import { useLiveQuery } from "dexie-react-hooks"; -import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom"; -import { AllSubscriptions, SingleSubscription } from "./Notifications"; -import theme from "./theme"; -import Navigation from "./Navigation"; -import ActionBar from "./ActionBar"; -import notifier from "../app/Notifier"; -import Preferences from "./Preferences"; -import subscriptionManager from "../app/SubscriptionManager"; -import userManager from "../app/UserManager"; -import { expandUrl } from "../app/utils"; -import ErrorBoundary from "./ErrorBoundary"; -import routes from "./routes"; -import { useAccountListener, useBackgroundProcesses, useConnectionListeners } from "./hooks"; -import PublishDialog from "./PublishDialog"; -import Messaging from "./Messaging"; -import "./i18n"; // Translations! -import Login from "./Login"; -import Signup from "./Signup"; -import Account from "./Account"; - -export const AccountContext = createContext(null); - -const App = () => { - const [account, setAccount] = useState(null); - const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]); - - return ( - }> - - - - - - - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - - - - - - - - ); -}; - -const updateTitle = (newNotificationsCount) => { - document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; -}; - -const Layout = () => { - const params = useParams(); - const { account, setAccount } = useContext(AccountContext); - const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); - const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted()); - const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); - const users = useLiveQuery(() => userManager.all()); - const subscriptions = useLiveQuery(() => subscriptionManager.all()); - const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal); - const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; - const [selected] = (subscriptionsWithoutInternal || []).filter( - (s) => - (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) || - (config.base_url === s.baseUrl && params.topic === s.topic) - ); - - useConnectionListeners(account, subscriptions, users); - useAccountListener(setAccount); - useBackgroundProcesses(); - useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); - - return ( - - setMobileDrawerOpen(!mobileDrawerOpen)} /> - setMobileDrawerOpen(!mobileDrawerOpen)} - onNotificationGranted={setNotificationsGranted} - onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} - /> -
- - -
- -
- ); -}; - -const Main = (props) => ( - (palette.mode === "light" ? palette.grey[100] : palette.grey[900]), - }} - > - {props.children} - -); - -const Loader = () => ( - (palette.mode === "light" ? palette.grey[100] : palette.grey[900]), - }} - > - - -); - -export default App; diff --git a/web/src/components/AttachmentIcon.js b/web/src/components/AttachmentIcon.js new file mode 100644 index 0000000..337760b --- /dev/null +++ b/web/src/components/AttachmentIcon.js @@ -0,0 +1,47 @@ +import * as React from "react"; +import Box from "@mui/material/Box"; +import fileDocument from "../img/file-document.svg"; +import fileImage from "../img/file-image.svg"; +import fileVideo from "../img/file-video.svg"; +import fileAudio from "../img/file-audio.svg"; +import fileApp from "../img/file-app.svg"; +import {useTranslation} from "react-i18next"; + +const AttachmentIcon = (props) => { + const { t } = useTranslation(); + const type = props.type; + let imageFile, imageLabel; + if (!type) { + imageFile = fileDocument; + imageLabel = t("notifications_attachment_file_image"); + } else if (type.startsWith('image/')) { + imageFile = fileImage; + imageLabel = t("notifications_attachment_file_video"); + } else if (type.startsWith('video/')) { + imageFile = fileVideo; + imageLabel = t("notifications_attachment_file_video"); + } else if (type.startsWith('audio/')) { + imageFile = fileAudio; + imageLabel = t("notifications_attachment_file_audio"); + } else if (type === "application/vnd.android.package-archive") { + imageFile = fileApp; + imageLabel = t("notifications_attachment_file_app"); + } else { + imageFile = fileDocument; + imageLabel = t("notifications_attachment_file_document"); + } + return ( + + ); +} + +export default AttachmentIcon; diff --git a/web/src/components/AttachmentIcon.jsx b/web/src/components/AttachmentIcon.jsx deleted file mode 100644 index 9a2581e..0000000 --- a/web/src/components/AttachmentIcon.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import * as React from "react"; -import { Box } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import fileDocument from "../img/file-document.svg"; -import fileImage from "../img/file-image.svg"; -import fileVideo from "../img/file-video.svg"; -import fileAudio from "../img/file-audio.svg"; -import fileApp from "../img/file-app.svg"; - -const AttachmentIcon = (props) => { - const { t } = useTranslation(); - const { type } = props; - let imageFile; - let imageLabel; - if (!type) { - imageFile = fileDocument; - imageLabel = t("notifications_attachment_file_image"); - } else if (type.startsWith("image/")) { - imageFile = fileImage; - imageLabel = t("notifications_attachment_file_video"); - } else if (type.startsWith("video/")) { - imageFile = fileVideo; - imageLabel = t("notifications_attachment_file_video"); - } else if (type.startsWith("audio/")) { - imageFile = fileAudio; - imageLabel = t("notifications_attachment_file_audio"); - } else if (type === "application/vnd.android.package-archive") { - imageFile = fileApp; - imageLabel = t("notifications_attachment_file_app"); - } else { - imageFile = fileDocument; - imageLabel = t("notifications_attachment_file_document"); - } - return ( - - ); -}; - -export default AttachmentIcon; diff --git a/web/src/components/AvatarBox.js b/web/src/components/AvatarBox.js new file mode 100644 index 0000000..2278f60 --- /dev/null +++ b/web/src/components/AvatarBox.js @@ -0,0 +1,29 @@ +import * as React from 'react'; +import {Avatar} from "@mui/material"; +import Box from "@mui/material/Box"; +import logo from "../img/ntfy-filled.svg"; + +const AvatarBox = (props) => { + return ( + + + {props.children} + + ); +} + +export default AvatarBox; diff --git a/web/src/components/AvatarBox.jsx b/web/src/components/AvatarBox.jsx deleted file mode 100644 index 1037868..0000000 --- a/web/src/components/AvatarBox.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from "react"; -import { Avatar, Box } from "@mui/material"; -import logo from "../img/ntfy-filled.svg"; - -const AvatarBox = (props) => ( - - - {props.children} - -); - -export default AvatarBox; diff --git a/web/src/components/DialogFooter.js b/web/src/components/DialogFooter.js new file mode 100644 index 0000000..68d17c7 --- /dev/null +++ b/web/src/components/DialogFooter.js @@ -0,0 +1,33 @@ +import * as React from "react"; +import Box from "@mui/material/Box"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogActions from "@mui/material/DialogActions"; + +const DialogFooter = (props) => { + return ( + + + {props.status} + + + {props.children} + + + ); +}; + +export default DialogFooter; diff --git a/web/src/components/DialogFooter.jsx b/web/src/components/DialogFooter.jsx deleted file mode 100644 index bcaf4cf..0000000 --- a/web/src/components/DialogFooter.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from "react"; -import { Box, DialogContentText, DialogActions } from "@mui/material"; - -const DialogFooter = (props) => ( - - - {props.status} - - {props.children} - -); - -export default DialogFooter; diff --git a/web/src/components/EmojiPicker.js b/web/src/components/EmojiPicker.js new file mode 100644 index 0000000..9b29e8f --- /dev/null +++ b/web/src/components/EmojiPicker.js @@ -0,0 +1,179 @@ +import * as React from 'react'; +import {useRef, useState} from 'react'; +import Typography from '@mui/material/Typography'; +import {rawEmojis} from '../app/emojis'; +import Box from "@mui/material/Box"; +import TextField from "@mui/material/TextField"; +import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material"; +import IconButton from "@mui/material/IconButton"; +import {Close} from "@mui/icons-material"; +import Popper from "@mui/material/Popper"; +import {splitNoEmpty} from "../app/utils"; +import {useTranslation} from "react-i18next"; + +// Create emoji list by category and create a search base (string with all search words) +// +// This also filters emojis that are not supported by Desktop Chrome. +// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported. + +const emojisByCategory = {}; +const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); +const maxSupportedVersionForDesktopChrome = 11; +rawEmojis.forEach(emoji => { + if (!emojisByCategory[emoji.category]) { + emojisByCategory[emoji.category] = []; + } + try { + const unicodeVersion = parseFloat(emoji.unicode_version); + const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; + if (supportedEmoji) { + const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; + const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; + emojisByCategory[emoji.category].push(emojiWithSearchBase); + } + } catch (e) { + // Nothing. Ignore. + } +}); + +const EmojiPicker = (props) => { + const { t } = useTranslation(); + const open = Boolean(props.anchorEl); + const [search, setSearch] = useState(""); + const searchRef = useRef(null); + const searchFields = splitNoEmpty(search.toLowerCase(), " "); + + const handleSearchClear = () => { + setSearch(""); + searchRef.current?.focus(); + }; + + return ( + + {({ TransitionProps }) => ( + + + + setSearch(ev.target.value)} + type="text" + variant="standard" + fullWidth + sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} + inputProps={{ + role: "searchbox", + "aria-label": t("emoji_picker_search_placeholder") + }} + InputProps={{ + endAdornment: + + + + + + }} + /> + + {Object.keys(emojisByCategory).map(category => + + )} + + + + + )} + + ); +}; + +const Category = (props) => { + const showTitle = props.search.length === 0; + return ( + <> + {showTitle && + + {props.title} + + } + {props.emojis.map(emoji => + props.onPick(emoji.aliases[0])} + /> + )} + + ); +}; + +const Emoji = (props) => { + const emoji = props.emoji; + const matches = emojiMatches(emoji, props.search); + const title = `${emoji.description} (${emoji.aliases[0]})`; + return ( + + {props.emoji.emoji} + + ); +}; + +const EmojiDiv = styled("div")({ + fontSize: "30px", + width: "30px", + height: "30px", + marginTop: "8px", + marginBottom: "8px", + marginRight: "8px", + lineHeight: "30px", + cursor: "pointer", + opacity: 0.85, + "&:hover": { + opacity: 1 + } +}); + +const emojiMatches = (emoji, words) => { + if (words.length === 0) { + return true; + } + for (const word of words) { + if (emoji.searchBase.indexOf(word) === -1) { + return false; + } + } + return true; +} + +export default EmojiPicker; diff --git a/web/src/components/EmojiPicker.jsx b/web/src/components/EmojiPicker.jsx deleted file mode 100644 index d1fb170..0000000 --- a/web/src/components/EmojiPicker.jsx +++ /dev/null @@ -1,158 +0,0 @@ -import * as React from "react"; -import { useRef, useState } from "react"; -import { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from "@mui/material"; -import { Close } from "@mui/icons-material"; -import { useTranslation } from "react-i18next"; -import { splitNoEmpty } from "../app/utils"; -import { rawEmojis } from "../app/emojis"; - -// Create emoji list by category and create a search base (string with all search words) -// -// This also filters emojis that are not supported by Desktop Chrome. -// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported. - -const emojisByCategory = {}; -const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); -const maxSupportedVersionForDesktopChrome = 11; -rawEmojis.forEach((emoji) => { - if (!emojisByCategory[emoji.category]) { - emojisByCategory[emoji.category] = []; - } - try { - const unicodeVersion = parseFloat(emoji.unicode_version); - const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; - if (supportedEmoji) { - const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; - const emojiWithSearchBase = { ...emoji, searchBase }; - emojisByCategory[emoji.category].push(emojiWithSearchBase); - } - } catch (e) { - // Nothing. Ignore. - } -}); - -const EmojiPicker = (props) => { - const { t } = useTranslation(); - const open = Boolean(props.anchorEl); - const [search, setSearch] = useState(""); - const searchRef = useRef(null); - const searchFields = splitNoEmpty(search.toLowerCase(), " "); - - const handleSearchClear = () => { - setSearch(""); - searchRef.current?.focus(); - }; - - return ( - - {({ TransitionProps }) => ( - - - - setSearch(ev.target.value)} - type="text" - variant="standard" - fullWidth - sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} - inputProps={{ - role: "searchbox", - "aria-label": t("emoji_picker_search_placeholder"), - }} - InputProps={{ - endAdornment: ( - - - - - - ), - }} - /> - - {Object.keys(emojisByCategory).map((category) => ( - - ))} - - - - - )} - - ); -}; - -const Category = (props) => { - const showTitle = props.search.length === 0; - return ( - <> - {showTitle && ( - - {props.title} - - )} - {props.emojis.map((emoji) => ( - props.onPick(emoji.aliases[0])} /> - ))} - - ); -}; - -const emojiMatches = (emoji, words) => words.length === 0 || words.some((word) => emoji.searchBase.includes(word)); - -const Emoji = (props) => { - const { emoji } = props; - const matches = emojiMatches(emoji, props.search); - const title = `${emoji.description} (${emoji.aliases[0]})`; - return ( - - {props.emoji.emoji} - - ); -}; - -const EmojiDiv = styled("div")({ - fontSize: "30px", - width: "30px", - height: "30px", - marginTop: "8px", - marginBottom: "8px", - marginRight: "8px", - lineHeight: "30px", - cursor: "pointer", - opacity: 0.85, - "&:hover": { - opacity: 1, - }, -}); - -export default EmojiPicker; diff --git a/web/src/components/ErrorBoundary.js b/web/src/components/ErrorBoundary.js new file mode 100644 index 0000000..c6d789a --- /dev/null +++ b/web/src/components/ErrorBoundary.js @@ -0,0 +1,129 @@ +import * as React from "react"; +import StackTrace from "stacktrace-js"; +import {CircularProgress, Link} from "@mui/material"; +import Button from "@mui/material/Button"; +import {Trans, withTranslation} from "react-i18next"; + +class ErrorBoundaryImpl extends React.Component { + constructor(props) { + super(props); + this.state = { + error: false, + originalStack: null, + niceStack: null, + unsupportedIndexedDB: false + }; + } + + componentDidCatch(error, info) { + console.error("[ErrorBoundary] Error caught", error, info); + + // Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see + // - https://github.com/dexie/Dexie.js/issues/312 + // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982 + const isUnsupportedIndexedDB = error?.name === "InvalidStateError" || + (error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1); + + if (isUnsupportedIndexedDB) { + this.handleUnsupportedIndexedDB(); + } else { + this.handleError(error, info); + } + } + + handleError(error, info) { + // Immediately render original stack trace + const prettierOriginalStack = info.componentStack + .trim() + .split("\n") + .map(line => ` at ${line}`) + .join("\n"); + this.setState({ + error: true, + originalStack: `${error.toString()}\n${prettierOriginalStack}` + }); + + // Fetch additional info and a better stack trace + StackTrace.fromError(error).then(stack => { + console.error("[ErrorBoundary] Stacktrace fetched", stack); + const niceStack = `${error.toString()}\n` + stack.map( el => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); + this.setState({ niceStack }); + }); + } + + handleUnsupportedIndexedDB() { + this.setState({ + error: true, + unsupportedIndexedDB: true + }); + } + + copyStack() { + let stack = ""; + if (this.state.niceStack) { + stack += `${this.state.niceStack}\n\n`; + } + stack += `${this.state.originalStack}\n`; + navigator.clipboard.writeText(stack); + } + + render() { + if (this.state.error) { + if (this.state.unsupportedIndexedDB) { + return this.renderUnsupportedIndexedDB(); + } else { + return this.renderError(); + } + } + return this.props.children; + } + + renderUnsupportedIndexedDB() { + const { t } = this.props; + return ( +
+

{t("error_boundary_unsupported_indexeddb_title")} 😮

+

+ , + discordLink: , + matrixLink: + }} + /> +

+
+ ); + } + + renderError() { + const { t } = this.props; + return ( +
+

{t("error_boundary_title")} 😮

+

+ , + discordLink: , + matrixLink: + }} + /> +

+

+ +

+

{t("error_boundary_stack_trace")}

+ {this.state.niceStack + ?
{this.state.niceStack}
+ : <> {t("error_boundary_gathering_info")}} +
{this.state.originalStack}
+
+ ); + } +} + +const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t +export default ErrorBoundary; diff --git a/web/src/components/ErrorBoundary.jsx b/web/src/components/ErrorBoundary.jsx deleted file mode 100644 index 9715c0c..0000000 --- a/web/src/components/ErrorBoundary.jsx +++ /dev/null @@ -1,134 +0,0 @@ -import * as React from "react"; -import StackTrace from "stacktrace-js"; -import { CircularProgress, Link, Button } from "@mui/material"; -import { Trans, withTranslation } from "react-i18next"; - -class ErrorBoundaryImpl extends React.Component { - constructor(props) { - super(props); - this.state = { - error: false, - originalStack: null, - niceStack: null, - unsupportedIndexedDB: false, - }; - } - - componentDidCatch(error, info) { - console.error("[ErrorBoundary] Error caught", error, info); - - // Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see - // - https://github.com/dexie/Dexie.js/issues/312 - // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982 - const isUnsupportedIndexedDB = - error?.name === "InvalidStateError" || (error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1); - - if (isUnsupportedIndexedDB) { - this.handleUnsupportedIndexedDB(); - } else { - this.handleError(error, info); - } - } - - handleError(error, info) { - // Immediately render original stack trace - const prettierOriginalStack = info.componentStack - .trim() - .split("\n") - .map((line) => ` at ${line}`) - .join("\n"); - this.setState({ - error: true, - originalStack: `${error.toString()}\n${prettierOriginalStack}`, - }); - - // Fetch additional info and a better stack trace - StackTrace.fromError(error).then((stack) => { - console.error("[ErrorBoundary] Stacktrace fetched", stack); - const stackString = stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); - const niceStack = `${error.toString()}\n${stackString}`; - this.setState({ niceStack }); - }); - } - - handleUnsupportedIndexedDB() { - this.setState({ - error: true, - unsupportedIndexedDB: true, - }); - } - - copyStack() { - let stack = ""; - if (this.state.niceStack) { - stack += `${this.state.niceStack}\n\n`; - } - stack += `${this.state.originalStack}\n`; - navigator.clipboard.writeText(stack); - } - - renderUnsupportedIndexedDB() { - const { t } = this.props; - return ( -
-

{t("error_boundary_unsupported_indexeddb_title")} 😮

-

- , - discordLink: , - matrixLink: , - }} - /> -

-
- ); - } - - renderError() { - const { t } = this.props; - return ( -
-

{t("error_boundary_title")} 😮

-

- , - discordLink: , - matrixLink: , - }} - /> -

-

- -

-

{t("error_boundary_stack_trace")}

- {this.state.niceStack ? ( -
{this.state.niceStack}
- ) : ( - <> - {t("error_boundary_gathering_info")} - - )} -
{this.state.originalStack}
-
- ); - } - - render() { - if (this.state.error) { - if (this.state.unsupportedIndexedDB) { - return this.renderUnsupportedIndexedDB(); - } - return this.renderError(); - } - return this.props.children; - } -} - -const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t -export default ErrorBoundary; diff --git a/web/src/components/Login.js b/web/src/components/Login.js new file mode 100644 index 0000000..8b14c53 --- /dev/null +++ b/web/src/components/Login.js @@ -0,0 +1,122 @@ +import * as React from 'react'; +import {useState} from 'react'; +import Typography from "@mui/material/Typography"; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; +import Box from "@mui/material/Box"; +import routes from "./routes"; +import session from "../app/Session"; +import {NavLink} from "react-router-dom"; +import AvatarBox from "./AvatarBox"; +import {useTranslation} from "react-i18next"; +import accountApi from "../app/AccountApi"; +import IconButton from "@mui/material/IconButton"; +import {InputAdornment} from "@mui/material"; +import {Visibility, VisibilityOff} from "@mui/icons-material"; +import {UnauthorizedError} from "../app/errors"; + +const Login = () => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + const user = { username, password }; + try { + const token = await accountApi.login(user); + console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); + session.store(user.username, token); + window.location.href = routes.app; + } catch (e) { + console.log(`[Login] User auth for user ${user.username} failed`, e); + if (e instanceof UnauthorizedError) { + setError(t("Login failed: Invalid username or password")); + } else { + setError(e.message); + } + } + }; + if (!config.enable_login) { + return ( + + {t("login_disabled")} + + ); + } + return ( + + + {t("login_title")} + + + setUsername(ev.target.value.trim())} + autoFocus + /> + setPassword(ev.target.value.trim())} + autoComplete="current-password" + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + onMouseDown={(ev) => ev.preventDefault()} + edge="end" + > + {showPassword ? : } + + + ) + }} + /> + + {error && + + + {error} + + } + + {/* This is where the password reset link would go */} + {config.enable_signup &&
{t("login_link_signup")}
} +
+
+
+ ); +} + +export default Login; diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx deleted file mode 100644 index 489eee0..0000000 --- a/web/src/components/Login.jsx +++ /dev/null @@ -1,117 +0,0 @@ -import * as React from "react"; -import { useState } from "react"; -import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material"; -import WarningAmberIcon from "@mui/icons-material/WarningAmber"; -import { NavLink } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { Visibility, VisibilityOff } from "@mui/icons-material"; -import accountApi from "../app/AccountApi"; -import AvatarBox from "./AvatarBox"; -import session from "../app/Session"; -import routes from "./routes"; -import { UnauthorizedError } from "../app/errors"; - -const Login = () => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); - - const handleSubmit = async (event) => { - event.preventDefault(); - const user = { username, password }; - try { - const token = await accountApi.login(user); - console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); - session.store(user.username, token); - window.location.href = routes.app; - } catch (e) { - console.log(`[Login] User auth for user ${user.username} failed`, e); - if (e instanceof UnauthorizedError) { - setError(t("Login failed: Invalid username or password")); - } else { - setError(e.message); - } - } - }; - if (!config.enable_login) { - return ( - - {t("login_disabled")} - - ); - } - return ( - - {t("login_title")} - - setUsername(ev.target.value.trim())} - autoFocus - /> - setPassword(ev.target.value.trim())} - autoComplete="current-password" - InputProps={{ - endAdornment: ( - - setShowPassword(!showPassword)} - onMouseDown={(ev) => ev.preventDefault()} - edge="end" - > - {showPassword ? : } - - - ), - }} - /> - - {error && ( - - - {error} - - )} - - {/* This is where the password reset link would go */} - {config.enable_signup && ( -
- - {t("login_link_signup")} - -
- )} -
-
-
- ); -}; - -export default Login; diff --git a/web/src/components/Messaging.js b/web/src/components/Messaging.js new file mode 100644 index 0000000..b1f11a9 --- /dev/null +++ b/web/src/components/Messaging.js @@ -0,0 +1,114 @@ +import * as React from 'react'; +import {useState} from 'react'; +import Navigation from "./Navigation"; +import Paper from "@mui/material/Paper"; +import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; +import SendIcon from "@mui/icons-material/Send"; +import api from "../app/Api"; +import PublishDialog from "./PublishDialog"; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import {Portal, Snackbar} from "@mui/material"; +import {useTranslation} from "react-i18next"; + +const Messaging = (props) => { + const [message, setMessage] = useState(""); + const [dialogKey, setDialogKey] = useState(0); + + const dialogOpenMode = props.dialogOpenMode; + const subscription = props.selected; + + const handleOpenDialogClick = () => { + props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT); + }; + + const handleDialogClose = () => { + props.onDialogOpenModeChange(""); + setDialogKey(prev => prev+1); + }; + + return ( + <> + {subscription && } + props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open + onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} + /> + + ); +} + +const MessageBar = (props) => { + const { t } = useTranslation(); + const subscription = props.subscription; + const [snackOpen, setSnackOpen] = useState(false); + const handleSendClick = async () => { + try { + await api.publish(subscription.baseUrl, subscription.topic, props.message); + } catch (e) { + console.log(`[MessageBar] Error publishing message`, e); + setSnackOpen(true); + } + props.onMessageChange(""); + }; + return ( + theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] + }} + > + + + + props.onMessageChange(ev.target.value)} + onKeyPress={(ev) => { + if (ev.key === 'Enter') { + ev.preventDefault(); + handleSendClick(); + } + }} + /> + + + + + setSnackOpen(false)} + message={t("message_bar_error_publishing")} + /> + + + ); +}; + +export default Messaging; diff --git a/web/src/components/Messaging.jsx b/web/src/components/Messaging.jsx deleted file mode 100644 index 27e08dc..0000000 --- a/web/src/components/Messaging.jsx +++ /dev/null @@ -1,108 +0,0 @@ -import * as React from "react"; -import { useState } from "react"; -import { Paper, IconButton, TextField, Portal, Snackbar } from "@mui/material"; -import SendIcon from "@mui/icons-material/Send"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import { useTranslation } from "react-i18next"; -import PublishDialog from "./PublishDialog"; -import api from "../app/Api"; -import Navigation from "./Navigation"; - -const Messaging = (props) => { - const [message, setMessage] = useState(""); - const [dialogKey, setDialogKey] = useState(0); - - const { dialogOpenMode } = props; - const subscription = props.selected; - - const handleOpenDialogClick = () => { - props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT); - }; - - const handleDialogClose = () => { - props.onDialogOpenModeChange(""); - setDialogKey((prev) => prev + 1); - }; - - return ( - <> - {subscription && ( - - )} - props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open - onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} - /> - - ); -}; - -const MessageBar = (props) => { - const { t } = useTranslation(); - const { subscription } = props; - const [snackOpen, setSnackOpen] = useState(false); - const handleSendClick = async () => { - try { - await api.publish(subscription.baseUrl, subscription.topic, props.message); - } catch (e) { - console.log(`[MessageBar] Error publishing message`, e); - setSnackOpen(true); - } - props.onMessageChange(""); - }; - return ( - (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), - }} - > - - - - props.onMessageChange(ev.target.value)} - onKeyPress={(ev) => { - if (ev.key === "Enter") { - ev.preventDefault(); - handleSendClick(); - } - }} - /> - - - - - setSnackOpen(false)} - message={t("message_bar_error_publishing")} - /> - - - ); -}; - -export default Messaging; diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js new file mode 100644 index 0000000..a7d0da0 --- /dev/null +++ b/web/src/components/Navigation.js @@ -0,0 +1,371 @@ +import Drawer from "@mui/material/Drawer"; +import * as React from "react"; +import {useContext, useState} from "react"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; +import Person from "@mui/icons-material/Person"; +import ListItemText from "@mui/material/ListItemText"; +import Toolbar from "@mui/material/Toolbar"; +import Divider from "@mui/material/Divider"; +import List from "@mui/material/List"; +import SettingsIcon from "@mui/icons-material/Settings"; +import AddIcon from "@mui/icons-material/Add"; +import SubscribeDialog from "./SubscribeDialog"; +import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip} from "@mui/material"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import {openUrl, topicDisplayName, topicUrl} from "../app/utils"; +import routes from "./routes"; +import {ConnectionState} from "../app/Connection"; +import {useLocation, useNavigate} from "react-router-dom"; +import subscriptionManager from "../app/SubscriptionManager"; +import {ChatBubble, MoreVert, NotificationsOffOutlined, Send} from "@mui/icons-material"; +import Box from "@mui/material/Box"; +import notifier from "../app/Notifier"; +import config from "../app/config"; +import ArticleIcon from '@mui/icons-material/Article'; +import {Trans, useTranslation} from "react-i18next"; +import session from "../app/Session"; +import accountApi, {Permission, Role} from "../app/AccountApi"; +import CelebrationIcon from '@mui/icons-material/Celebration'; +import UpgradeDialog from "./UpgradeDialog"; +import {AccountContext} from "./App"; +import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; +import IconButton from "@mui/material/IconButton"; +import { SubscriptionPopup } from "./SubscriptionPopup"; + +const navWidth = 280; + +const Navigation = (props) => { + const navigationList = ; + return ( + + {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} + + {navigationList} + + {/* Big screen drawer; persistent, shown if screen is big */} + + {navigationList} + + + ); +}; +Navigation.width = navWidth; + +const NavList = (props) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const { account } = useContext(AccountContext); + const [subscribeDialogKey, setSubscribeDialogKey] = useState(0); + const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); + + const handleSubscribeReset = () => { + setSubscribeDialogOpen(false); + setSubscribeDialogKey(prev => prev+1); + } + + const handleSubscribeSubmit = (subscription) => { + console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); + handleSubscribeReset(); + navigate(routes.forSubscription(subscription)); + handleRequestNotificationPermission(); + } + + const handleRequestNotificationPermission = () => { + notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted)) + }; + + const handleAccountClick = () => { + accountApi.sync(); // Dangle! + navigate(routes.account); + }; + + const isAdmin = account?.role === Role.ADMIN; + const isPaid = account?.billing?.subscription; + const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; + const showSubscriptionsList = props.subscriptions?.length > 0; + const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); + const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser + const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted; + const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : ''; + + return ( + <> + + + {showNotificationBrowserNotSupportedBox && } + {showNotificationContextNotSupportedBox && } + {showNotificationGrantBox && } + {!showSubscriptionsList && + navigate(routes.app)} selected={location.pathname === config.app_root}> + + + } + {showSubscriptionsList && + <> + {t("nav_topics_title")} + navigate(routes.app)} selected={location.pathname === config.app_root}> + + + + + + } + {session.exists() && + + + + + } + navigate(routes.settings)} selected={location.pathname === routes.settings}> + + + + openUrl("/docs")}> + + + + props.onPublishMessageClick()}> + + + + setSubscribeDialogOpen(true)}> + + + + {showUpgradeBanner && + + } + + + + ); +}; + +const UpgradeBanner = () => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + + const handleClick = () => { + setDialogKey(k => k + 1); + setDialogOpen(true); + }; + + return ( + + + + + + + setDialogOpen(false)} + /> + + ); +}; + +const SubscriptionList = (props) => { + const sortedSubscriptions = props.subscriptions + .filter(s => !s.internal) + .sort((a, b) => { + return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1; + }); + return ( + <> + {sortedSubscriptions.map(subscription => + )} + + ); +} + +const SubscriptionItem = (props) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + + const subscription = props.subscription; + const iconBadge = (subscription.new <= 99) ? subscription.new : "99+"; + const displayName = topicDisplayName(subscription); + const ariaLabel = (subscription.state === ConnectionState.Connecting) + ? `${displayName} (${t("nav_button_connecting")})` + : displayName; + const icon = (subscription.state === ConnectionState.Connecting) + ? + : ; + + const handleClick = async () => { + navigate(routes.forSubscription(subscription)); + await subscriptionManager.markNotificationsRead(subscription.id); + }; + + return ( + <> + + {icon} + + {subscription.reservation?.everyone && + + {subscription.reservation?.everyone === Permission.READ_WRITE && + + } + {subscription.reservation?.everyone === Permission.READ_ONLY && + + } + {subscription.reservation?.everyone === Permission.WRITE_ONLY && + + } + {subscription.reservation?.everyone === Permission.DENY_ALL && + + } + + } + {subscription.mutedUntil > 0 && + + + + } + + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + setMenuAnchorEl(e.currentTarget); + }} + > + + + + + + setMenuAnchorEl(null)} + /> + + + ); +}; + +const NotificationGrantAlert = (props) => { + const { t } = useTranslation(); + return ( + <> + + {t("alert_grant_title")} + {t("alert_grant_description")} + + + + + ); +}; + +const NotificationBrowserNotSupportedAlert = () => { + const { t } = useTranslation(); + return ( + <> + + {t("alert_not_supported_title")} + {t("alert_not_supported_description")} + + + + ); +}; + +const NotificationContextNotSupportedAlert = () => { + const { t } = useTranslation(); + return ( + <> + + {t("alert_not_supported_title")} + + + }} + /> + + + + + ); +}; + +export default Navigation; diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx deleted file mode 100644 index 8cbefec..0000000 --- a/web/src/components/Navigation.jsx +++ /dev/null @@ -1,396 +0,0 @@ -import { - Drawer, - ListItemButton, - ListItemIcon, - ListItemText, - Toolbar, - Divider, - List, - Alert, - AlertTitle, - Badge, - CircularProgress, - Link, - ListSubheader, - Portal, - Tooltip, - Button, - Typography, - Box, - IconButton, -} from "@mui/material"; -import * as React from "react"; -import { useContext, useState } from "react"; -import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; -import Person from "@mui/icons-material/Person"; -import SettingsIcon from "@mui/icons-material/Settings"; -import AddIcon from "@mui/icons-material/Add"; -import { useLocation, useNavigate } from "react-router-dom"; -import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material"; -import ArticleIcon from "@mui/icons-material/Article"; -import { Trans, useTranslation } from "react-i18next"; -import CelebrationIcon from "@mui/icons-material/Celebration"; -import SubscribeDialog from "./SubscribeDialog"; -import { openUrl, topicDisplayName, topicUrl } from "../app/utils"; -import routes from "./routes"; -import { ConnectionState } from "../app/Connection"; -import subscriptionManager from "../app/SubscriptionManager"; -import notifier from "../app/Notifier"; -import config from "../app/config"; -import session from "../app/Session"; -import accountApi, { Permission, Role } from "../app/AccountApi"; -import UpgradeDialog from "./UpgradeDialog"; -import { AccountContext } from "./App"; -import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; -import { SubscriptionPopup } from "./SubscriptionPopup"; - -const navWidth = 280; - -const Navigation = (props) => { - const navigationList = ; - return ( - - {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} - - {navigationList} - - {/* Big screen drawer; persistent, shown if screen is big */} - - {navigationList} - - - ); -}; -Navigation.width = navWidth; - -const NavList = (props) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const location = useLocation(); - const { account } = useContext(AccountContext); - const [subscribeDialogKey, setSubscribeDialogKey] = useState(0); - const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); - - const handleSubscribeReset = () => { - setSubscribeDialogOpen(false); - setSubscribeDialogKey((prev) => prev + 1); - }; - - const handleRequestNotificationPermission = () => { - notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted)); - }; - - const handleSubscribeSubmit = (subscription) => { - console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); - handleSubscribeReset(); - navigate(routes.forSubscription(subscription)); - handleRequestNotificationPermission(); - }; - - const handleAccountClick = () => { - accountApi.sync(); // Dangle! - navigate(routes.account); - }; - - const isAdmin = account?.role === Role.ADMIN; - const isPaid = account?.billing?.subscription; - const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; - const showSubscriptionsList = props.subscriptions?.length > 0; - const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); - const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser - const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted; - const navListPadding = - showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox ? "0" : ""; - - return ( - <> - - - {showNotificationBrowserNotSupportedBox && } - {showNotificationContextNotSupportedBox && } - {showNotificationGrantBox && } - {!showSubscriptionsList && ( - navigate(routes.app)} selected={location.pathname === config.app_root}> - - - - - - )} - {showSubscriptionsList && ( - <> - {t("nav_topics_title")} - navigate(routes.app)} selected={location.pathname === config.app_root}> - - - - - - - - - )} - {session.exists() && ( - - - - - - - )} - navigate(routes.settings)} selected={location.pathname === routes.settings}> - - - - - - openUrl("/docs")}> - - - - - - props.onPublishMessageClick()}> - - - - - - setSubscribeDialogOpen(true)}> - - - - - - {showUpgradeBanner && } - - - - ); -}; - -const UpgradeBanner = () => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - - const handleClick = () => { - setDialogKey((k) => k + 1); - setDialogOpen(true); - }; - - return ( - - - - - - - - - setDialogOpen(false)} /> - - ); -}; - -const SubscriptionList = (props) => { - const sortedSubscriptions = props.subscriptions - .filter((s) => !s.internal) - .sort((a, b) => (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1)); - return ( - <> - {sortedSubscriptions.map((subscription) => ( - - ))} - - ); -}; - -const SubscriptionItem = (props) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [menuAnchorEl, setMenuAnchorEl] = useState(null); - - const { subscription } = props; - const iconBadge = subscription.new <= 99 ? subscription.new : "99+"; - const displayName = topicDisplayName(subscription); - const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName; - const icon = - subscription.state === ConnectionState.Connecting ? ( - - ) : ( - - - - ); - - const handleClick = async () => { - navigate(routes.forSubscription(subscription)); - await subscriptionManager.markNotificationsRead(subscription.id); - }; - - return ( - <> - - {icon} - - {subscription.reservation?.everyone && ( - - {subscription.reservation?.everyone === Permission.READ_WRITE && ( - - - - )} - {subscription.reservation?.everyone === Permission.READ_ONLY && ( - - - - )} - {subscription.reservation?.everyone === Permission.WRITE_ONLY && ( - - - - )} - {subscription.reservation?.everyone === Permission.DENY_ALL && ( - - - - )} - - )} - {subscription.mutedUntil > 0 && ( - - - - - - )} - - e.stopPropagation()} - onClick={(e) => { - e.stopPropagation(); - setMenuAnchorEl(e.currentTarget); - }} - > - - - - - - setMenuAnchorEl(null)} /> - - - ); -}; - -const NotificationGrantAlert = (props) => { - const { t } = useTranslation(); - return ( - <> - - {t("alert_grant_title")} - {t("alert_grant_description")} - - - - - ); -}; - -const NotificationBrowserNotSupportedAlert = () => { - const { t } = useTranslation(); - return ( - <> - - {t("alert_not_supported_title")} - {t("alert_not_supported_description")} - - - - ); -}; - -const NotificationContextNotSupportedAlert = () => { - const { t } = useTranslation(); - return ( - <> - - {t("alert_not_supported_title")} - - , - }} - /> - - - - - ); -}; - -export default Navigation; diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.js new file mode 100644 index 0000000..10bcad8 --- /dev/null +++ b/web/src/components/Notifications.js @@ -0,0 +1,548 @@ +import Container from "@mui/material/Container"; +import { + ButtonBase, + CardActions, + CardContent, + CircularProgress, + Fade, + Link, + Modal, + Snackbar, + Stack, + Tooltip +} from "@mui/material"; +import Card from "@mui/material/Card"; +import Typography from "@mui/material/Typography"; +import * as React from "react"; +import {useEffect, useState} from "react"; +import { + formatBytes, + formatMessage, + formatShortDateTime, + formatTitle, + maybeAppendActionErrors, + openUrl, + shortUrl, + topicShortUrl, + unmatchedTags +} from "../app/utils"; +import IconButton from "@mui/material/IconButton"; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles"; +import {useLiveQuery} from "dexie-react-hooks"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import subscriptionManager from "../app/SubscriptionManager"; +import InfiniteScroll from "react-infinite-scroll-component"; +import priority1 from "../img/priority-1.svg"; +import priority2 from "../img/priority-2.svg"; +import priority4 from "../img/priority-4.svg"; +import priority5 from "../img/priority-5.svg"; +import logoOutline from "../img/ntfy-outline.svg"; +import AttachmentIcon from "./AttachmentIcon"; +import {Trans, useTranslation} from "react-i18next"; +import {useOutletContext} from "react-router-dom"; +import {useAutoSubscribe} from "./hooks"; + +export const AllSubscriptions = () => { + const { subscriptions } = useOutletContext(); + if (!subscriptions) { + return ; + } + return ; +}; + +export const SingleSubscription = () => { + const { subscriptions, selected } = useOutletContext(); + useAutoSubscribe(subscriptions, selected); + if (!selected) { + return ; + } + return ; +}; + +const AllSubscriptionsList = (props) => { + const subscriptions = props.subscriptions; + const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); + if (notifications === null || notifications === undefined) { + return ; + } else if (subscriptions.length === 0) { + return ; + } else if (notifications.length === 0) { + return ; + } + return ; +} + +const SingleSubscriptionList = (props) => { + const subscription = props.subscription; + const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); + if (notifications === null || notifications === undefined) { + return ; + } else if (notifications.length === 0) { + return ; + } + return ; +} + +const NotificationList = (props) => { + const { t } = useTranslation(); + const pageSize = 20; + const notifications = props.notifications; + const [snackOpen, setSnackOpen] = useState(false); + const [maxCount, setMaxCount] = useState(pageSize); + const count = Math.min(notifications.length, maxCount); + + useEffect(() => { + return () => { + setMaxCount(pageSize); + const main = document.getElementById("main"); + if (main) { + main.scrollTo(0, 0); + } + } + }, [props.id]); + + return ( + setMaxCount(prev => prev + pageSize)} + hasMore={count < notifications.length} + loader={<>Loading ...} + scrollThreshold={0.7} + scrollableTarget="main" + > + + + {notifications.slice(0, count).map(notification => + setSnackOpen(true)} + />)} + setSnackOpen(false)} + message={t("notifications_copied_to_clipboard")} + /> + + + + ); +} + +const NotificationItem = (props) => { + const { t } = useTranslation(); + const notification = props.notification; + const attachment = notification.attachment; + const date = formatShortDateTime(notification.time); + const otherTags = unmatchedTags(notification.tags); + const tags = (otherTags.length > 0) ? otherTags.join(', ') : null; + const handleDelete = async () => { + console.log(`[Notifications] Deleting notification ${notification.id}`); + await subscriptionManager.deleteNotification(notification.id) + } + const handleMarkRead = async () => { + console.log(`[Notifications] Marking notification ${notification.id} as read`); + await subscriptionManager.markNotificationRead(notification.id) + } + const handleCopy = (s) => { + navigator.clipboard.writeText(s); + props.onShowSnack(); + }; + const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000; + const hasAttachmentActions = attachment && !expired; + const hasClickAction = notification.click; + const hasUserActions = notification.actions && notification.actions.length > 0; + const showActions = hasAttachmentActions || hasClickAction || hasUserActions; + return ( + + + + + + + + {notification.new === 1 && + + + + + } + + {date} + {[1,2,4,5].includes(notification.priority) && + {t("notifications_priority_x",} + {notification.new === 1 && + + + } + + {notification.title && {formatTitle(notification)}} + + {autolink(maybeAppendActionErrors(formatMessage(notification), notification))} + + {attachment && } + {tags && {t("notifications_tags")}: {tags}} + + {showActions && + + {hasAttachmentActions && <> + + + + + + + } + {hasClickAction && <> + + + + + + + } + {hasUserActions && } + } + + ); +} + +/** + * Replace links with components; this is a combination of the genius function + * in [1] and the regex in [2]. + * + * [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760 + * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9 + */ +const autolink = (s) => { + const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi); + for (let i = 1; i < parts.length; i += 2) { + parts[i] = {shortUrl(parts[i])}; + } + return <>{parts}; +}; + +const priorityFiles = { + 1: priority1, + 2: priority2, + 4: priority4, + 5: priority5 +}; + +const Attachment = (props) => { + const { t } = useTranslation(); + const attachment = props.attachment; + const expired = attachment.expires && attachment.expires < Date.now()/1000; + const expires = attachment.expires && attachment.expires > Date.now()/1000; + const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/"); + + // Unexpired image + if (displayableImage) { + return ; + } + + // Anything else: Show box + const infos = []; + if (attachment.size) { + infos.push(formatBytes(attachment.size)); + } + if (expires) { + infos.push(t("notifications_attachment_link_expires", { date: formatShortDateTime(attachment.expires) })); + } + if (expired) { + infos.push(t("notifications_attachment_link_expired")); + } + const maybeInfoText = (infos.length > 0) ? <>
{infos.join(", ")} : null; + + // If expired, just show infos without click target + if (expired) { + return ( + + + + {attachment.name} + {maybeInfoText} + + + ); + } + + // Not expired + return ( + + + + + {attachment.name} + {maybeInfoText} + + + + ); +}; + +const Image = (props) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + return ( + <> + setOpen(true)} + sx={{ + marginTop: 2, + borderRadius: '4px', + boxShadow: 2, + width: 1, + maxHeight: '400px', + objectFit: 'cover', + cursor: 'pointer' + }} + /> + setOpen(false)} + BackdropComponent={LightboxBackdrop} + > + + + + + + ); +} + +const UserActions = (props) => { + return ( + <>{props.notification.actions.map(action => + )} + ); +}; + +const UserAction = (props) => { + const { t } = useTranslation(); + const notification = props.notification; + const action = props.action; + if (action.action === "broadcast") { + return ( + + + + ); + } else if (action.action === "view") { + return ( + + + + ); + } else if (action.action === "http") { + const method = action.method ?? "POST"; + const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); + return ( + + + + ); + } + return null; // Others +}; + +const performHttpAction = async (notification, action) => { + console.log(`[Notifications] Performing HTTP user action`, action); + try { + updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null); + const response = await fetch(action.url, { + method: action.method ?? "POST", + headers: action.headers ?? {}, + // This must not null-coalesce to a non nullish value. Otherwise, the fetch API + // will reject it for "having a body" + body: action.body + }); + console.log(`[Notifications] HTTP user action response`, response); + const success = response.status >= 200 && response.status <= 299; + if (success) { + updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); + } else { + updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`); + } + } catch (e) { + console.log(`[Notifications] HTTP action failed`, e); + updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`); + } +}; + +const updateActionStatus = (notification, action, progress, error) => { + notification.actions = notification.actions.map(a => { + if (a.id !== action.id) { + return a; + } + return { ...a, progress: progress, error: error }; + }); + subscriptionManager.updateNotification(notification); +} + +const ACTION_PROGRESS_ONGOING = 1; +const ACTION_PROGRESS_SUCCESS = 2; +const ACTION_PROGRESS_FAILED = 3; + +const ACTION_LABEL_SUFFIX = { + [ACTION_PROGRESS_ONGOING]: " …", + [ACTION_PROGRESS_SUCCESS]: " ✔", + [ACTION_PROGRESS_FAILED]: " ❌" +}; + +const NoNotifications = (props) => { + const { t } = useTranslation(); + const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); + return ( + + + {t("action_bar_logo_alt")}/
+ {t("notifications_none_for_topic_title")} +
+ + {t("notifications_none_for_topic_description")} + + + {t("notifications_example")}:
+ + $ curl -d "Hi" {shortUrl} + +
+ + + +
+ ); +}; + +const NoNotificationsWithoutSubscription = (props) => { + const { t } = useTranslation(); + const subscription = props.subscriptions[0]; + const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); + return ( + + + {t("action_bar_logo_alt")}/
+ {t("notifications_none_for_any_title")} +
+ + {t("notifications_none_for_any_description")} + + + {t("notifications_example")}:
+ + $ curl -d "Hi" {shortUrl} + +
+ + + +
+ ); +}; + +const NoSubscriptions = () => { + const { t } = useTranslation(); + return ( + + + {t("action_bar_logo_alt")}/
+ {t("notifications_no_subscriptions_title")} +
+ + {t("notifications_no_subscriptions_description", { + linktext: t("nav_button_subscribe") + })} + + + + +
+ ); +}; + +const ForMoreDetails = () => { + return ( + , + docsLink: + }} + /> + ); +}; + +const Loading = () => { + const { t } = useTranslation(); + return ( + + +
+ {t("notifications_loading")} +
+
+ ); +}; diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx deleted file mode 100644 index 2faf2fd..0000000 --- a/web/src/components/Notifications.jsx +++ /dev/null @@ -1,616 +0,0 @@ -import { - Container, - ButtonBase, - CardActions, - CardContent, - CircularProgress, - Fade, - Link, - Modal, - Snackbar, - Stack, - Tooltip, - Card, - Typography, - IconButton, - Box, - Button, -} from "@mui/material"; -import * as React from "react"; -import { useEffect, useState } from "react"; -import CheckIcon from "@mui/icons-material/Check"; -import CloseIcon from "@mui/icons-material/Close"; -import { useLiveQuery } from "dexie-react-hooks"; -import InfiniteScroll from "react-infinite-scroll-component"; -import { Trans, useTranslation } from "react-i18next"; -import { useOutletContext } from "react-router-dom"; -import { - formatBytes, - formatMessage, - formatShortDateTime, - formatTitle, - maybeAppendActionErrors, - openUrl, - shortUrl, - topicShortUrl, - unmatchedTags, -} from "../app/utils"; -import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles"; -import subscriptionManager from "../app/SubscriptionManager"; -import priority1 from "../img/priority-1.svg"; -import priority2 from "../img/priority-2.svg"; -import priority4 from "../img/priority-4.svg"; -import priority5 from "../img/priority-5.svg"; -import logoOutline from "../img/ntfy-outline.svg"; -import AttachmentIcon from "./AttachmentIcon"; -import { useAutoSubscribe } from "./hooks"; - -const priorityFiles = { - 1: priority1, - 2: priority2, - 4: priority4, - 5: priority5, -}; - -export const AllSubscriptions = () => { - const { subscriptions } = useOutletContext(); - if (!subscriptions) { - return ; - } - return ; -}; - -export const SingleSubscription = () => { - const { subscriptions, selected } = useOutletContext(); - useAutoSubscribe(subscriptions, selected); - if (!selected) { - return ; - } - return ; -}; - -const AllSubscriptionsList = (props) => { - const { subscriptions } = props; - const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); - if (notifications === null || notifications === undefined) { - return ; - } - if (subscriptions.length === 0) { - return ; - } - if (notifications.length === 0) { - return ; - } - return ; -}; - -const SingleSubscriptionList = (props) => { - const { subscription } = props; - const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); - if (notifications === null || notifications === undefined) { - return ; - } - if (notifications.length === 0) { - return ; - } - return ; -}; - -const NotificationList = (props) => { - const { t } = useTranslation(); - const pageSize = 20; - const { notifications } = props; - const [snackOpen, setSnackOpen] = useState(false); - const [maxCount, setMaxCount] = useState(pageSize); - const count = Math.min(notifications.length, maxCount); - - useEffect( - () => () => { - setMaxCount(pageSize); - const main = document.getElementById("main"); - if (main) { - main.scrollTo(0, 0); - } - }, - [props.id] - ); - - return ( - setMaxCount((prev) => prev + pageSize)} - hasMore={count < notifications.length} - loader={<>Loading ...} - scrollThreshold={0.7} - scrollableTarget="main" - > - - - {notifications.slice(0, count).map((notification) => ( - setSnackOpen(true)} /> - ))} - setSnackOpen(false)} - message={t("notifications_copied_to_clipboard")} - /> - - - - ); -}; - -/** - * Replace links with components; this is a combination of the genius function - * in [1] and the regex in [2]. - * - * [1] https://github.com/facebook/react/issues/3386#issuecomment-78605760 - * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9 - */ -const autolink = (s) => { - const parts = s.split(/(\bhttps?:\/\/[-A-Z0-9+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|]\b)/gi); - for (let i = 1; i < parts.length; i += 2) { - parts[i] = ( - - {shortUrl(parts[i])} - - ); - } - return <>{parts}; -}; - -const NotificationItem = (props) => { - const { t } = useTranslation(); - const { notification } = props; - const { attachment } = notification; - const date = formatShortDateTime(notification.time); - const otherTags = unmatchedTags(notification.tags); - const tags = otherTags.length > 0 ? otherTags.join(", ") : null; - const handleDelete = async () => { - console.log(`[Notifications] Deleting notification ${notification.id}`); - await subscriptionManager.deleteNotification(notification.id); - }; - const handleMarkRead = async () => { - console.log(`[Notifications] Marking notification ${notification.id} as read`); - await subscriptionManager.markNotificationRead(notification.id); - }; - const handleCopy = (s) => { - navigator.clipboard.writeText(s); - props.onShowSnack(); - }; - const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000; - const hasAttachmentActions = attachment && !expired; - const hasClickAction = notification.click; - const hasUserActions = notification.actions && notification.actions.length > 0; - const showActions = hasAttachmentActions || hasClickAction || hasUserActions; - return ( - - - - - - - - {notification.new === 1 && ( - - - - - - )} - - {date} - {[1, 2, 4, 5].includes(notification.priority) && ( - {t("notifications_priority_x", - )} - {notification.new === 1 && ( - - - - )} - - {notification.title && ( - - {formatTitle(notification)} - - )} - - {autolink(maybeAppendActionErrors(formatMessage(notification), notification))} - - {attachment && } - {tags && ( - - {t("notifications_tags")}: {tags} - - )} - - {showActions && ( - - {hasAttachmentActions && ( - <> - - - - - - - - )} - {hasClickAction && ( - <> - - - - - - - - )} - {hasUserActions && } - - )} - - ); -}; - -const Attachment = (props) => { - const { t } = useTranslation(); - const { attachment } = props; - const expired = attachment.expires && attachment.expires < Date.now() / 1000; - const expires = attachment.expires && attachment.expires > Date.now() / 1000; - const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/"); - - // Unexpired image - if (displayableImage) { - return ; - } - - // Anything else: Show box - const infos = []; - if (attachment.size) { - infos.push(formatBytes(attachment.size)); - } - if (expires) { - infos.push( - t("notifications_attachment_link_expires", { - date: formatShortDateTime(attachment.expires), - }) - ); - } - if (expired) { - infos.push(t("notifications_attachment_link_expired")); - } - const maybeInfoText = - infos.length > 0 ? ( - <> -
- {infos.join(", ")} - - ) : null; - - // If expired, just show infos without click target - if (expired) { - return ( - - - - {attachment.name} - {maybeInfoText} - - - ); - } - - // Not expired - return ( - - - - - {attachment.name} - {maybeInfoText} - - - - ); -}; - -const Image = (props) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - return ( - <> - setOpen(true)} - sx={{ - marginTop: 2, - borderRadius: "4px", - boxShadow: 2, - width: 1, - maxHeight: "400px", - objectFit: "cover", - cursor: "pointer", - }} - /> - setOpen(false)} BackdropComponent={LightboxBackdrop}> - - - - - - ); -}; - -const UserActions = (props) => ( - <> - {props.notification.actions.map((action) => ( - - ))} - -); - -const ACTION_PROGRESS_ONGOING = 1; -const ACTION_PROGRESS_SUCCESS = 2; -const ACTION_PROGRESS_FAILED = 3; - -const ACTION_LABEL_SUFFIX = { - [ACTION_PROGRESS_ONGOING]: " …", - [ACTION_PROGRESS_SUCCESS]: " ✔", - [ACTION_PROGRESS_FAILED]: " ❌", -}; - -const updateActionStatus = (notification, action, progress, error) => { - subscriptionManager.updateNotification({ - ...notification, - actions: notification.actions.map((a) => (a.id === action.id ? { ...a, progress, error } : a)), - }); -}; - -const performHttpAction = async (notification, action) => { - console.log(`[Notifications] Performing HTTP user action`, action); - try { - updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null); - const response = await fetch(action.url, { - method: action.method ?? "POST", - headers: action.headers ?? {}, - // This must not null-coalesce to a non nullish value. Otherwise, the fetch API - // will reject it for "having a body" - body: action.body, - }); - console.log(`[Notifications] HTTP user action response`, response); - const success = response.status >= 200 && response.status <= 299; - if (success) { - updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); - } else { - updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`); - } - } catch (e) { - console.log(`[Notifications] HTTP action failed`, e); - updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`); - } -}; - -const UserAction = (props) => { - const { t } = useTranslation(); - const { notification } = props; - const { action } = props; - if (action.action === "broadcast") { - return ( - - - - - - ); - } - if (action.action === "view") { - return ( - - - - ); - } - if (action.action === "http") { - const method = action.method ?? "POST"; - const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); - return ( - - - - ); - } - return null; // Others -}; - -const NoNotifications = (props) => { - const { t } = useTranslation(); - const topicShortUrlResolved = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); - return ( - - - {t("action_bar_logo_alt")} -
- {t("notifications_none_for_topic_title")} -
- {t("notifications_none_for_topic_description")} - - {t("notifications_example")}:
- - {'$ curl -d "Hi" '} - {topicShortUrlResolved} - -
- - - -
- ); -}; - -const NoNotificationsWithoutSubscription = (props) => { - const { t } = useTranslation(); - const subscription = props.subscriptions[0]; - const topicShortUrlResolved = topicShortUrl(subscription.baseUrl, subscription.topic); - return ( - - - {t("action_bar_logo_alt")} -
- {t("notifications_none_for_any_title")} -
- {t("notifications_none_for_any_description")} - - {t("notifications_example")}:
- - {'$ curl -d "Hi" '} - {topicShortUrlResolved} - -
- - - -
- ); -}; - -const NoSubscriptions = () => { - const { t } = useTranslation(); - return ( - - - {t("action_bar_logo_alt")} -
- {t("notifications_no_subscriptions_title")} -
- - {t("notifications_no_subscriptions_description", { - linktext: t("nav_button_subscribe"), - })} - - - - -
- ); -}; - -const ForMoreDetails = () => ( - , - docsLink: , - }} - /> -); - -const Loading = () => { - const { t } = useTranslation(); - return ( - - - -
- {t("notifications_loading")} -
-
- ); -}; diff --git a/web/src/components/PopupMenu.js b/web/src/components/PopupMenu.js new file mode 100644 index 0000000..4d22398 --- /dev/null +++ b/web/src/components/PopupMenu.js @@ -0,0 +1,48 @@ +import {Fade, Menu} from "@mui/material"; +import * as React from "react"; + +const PopupMenu = (props) => { + const horizontal = props.horizontal ?? "left"; + const arrow = (horizontal === "right") ? { right: 19 } : { left: 19 }; + return ( + + {props.children} + + ); +}; + +export default PopupMenu; diff --git a/web/src/components/PopupMenu.jsx b/web/src/components/PopupMenu.jsx deleted file mode 100644 index 89b2011..0000000 --- a/web/src/components/PopupMenu.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Fade, Menu } from "@mui/material"; -import * as React from "react"; - -const PopupMenu = (props) => { - const horizontal = props.horizontal ?? "left"; - const arrow = horizontal === "right" ? { right: 19 } : { left: 19 }; - return ( - - {props.children} - - ); -}; - -export default PopupMenu; diff --git a/web/src/components/Pref.js b/web/src/components/Pref.js new file mode 100644 index 0000000..622d9bb --- /dev/null +++ b/web/src/components/Pref.js @@ -0,0 +1,51 @@ +import * as React from "react"; + +export const PrefGroup = (props) => { + return ( +
+ {props.children} +
+ ) +}; + +export const Pref = (props) => { + const justifyContent = (props.alignTop) ? "normal" : "center"; + return ( +
+
+
{props.title}{props.subtitle && ({props.subtitle})}
+ {props.description &&
{props.description}
} +
+
+ {props.children} +
+
+ ); +}; diff --git a/web/src/components/Pref.jsx b/web/src/components/Pref.jsx deleted file mode 100644 index a725d11..0000000 --- a/web/src/components/Pref.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import * as React from "react"; - -export const PrefGroup = (props) =>
{props.children}
; - -export const Pref = (props) => { - const justifyContent = props.alignTop ? "normal" : "center"; - return ( -
-
-
- {props.title} - {props.subtitle && ({props.subtitle})} -
- {props.description && ( -
- {props.description} -
- )} -
-
- {props.children} -
-
- ); -}; diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js new file mode 100644 index 0000000..f8af3ae --- /dev/null +++ b/web/src/components/Preferences.js @@ -0,0 +1,644 @@ +import * as React from 'react'; +import {useContext, useEffect, useState} from 'react'; +import { + Alert, + CardActions, + CardContent, + Chip, + FormControl, + Select, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + useMediaQuery +} from "@mui/material"; +import Typography from "@mui/material/Typography"; +import prefs from "../app/Prefs"; +import {Paragraph} from "./styles"; +import EditIcon from '@mui/icons-material/Edit'; +import CloseIcon from "@mui/icons-material/Close"; +import IconButton from "@mui/material/IconButton"; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import Container from "@mui/material/Container"; +import TextField from "@mui/material/TextField"; +import MenuItem from "@mui/material/MenuItem"; +import Card from "@mui/material/Card"; +import Button from "@mui/material/Button"; +import {useLiveQuery} from "dexie-react-hooks"; +import theme from "./theme"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; +import userManager from "../app/UserManager"; +import {playSound, shuffle, sounds, validUrl} from "../app/utils"; +import {useTranslation} from "react-i18next"; +import session from "../app/Session"; +import routes from "./routes"; +import accountApi, {Permission, Role} from "../app/AccountApi"; +import {Pref, PrefGroup} from "./Pref"; +import {Info} from "@mui/icons-material"; +import {AccountContext} from "./App"; +import {useOutletContext} from "react-router-dom"; +import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; +import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs"; +import {UnauthorizedError} from "../app/errors"; +import subscriptionManager from "../app/SubscriptionManager"; +import {subscribeTopic} from "./SubscribeDialog"; + +const Preferences = () => { + return ( + + + + + + + + + ); +}; + +const Notifications = () => { + const { t } = useTranslation(); + return ( + + + {t("prefs_notifications_title")} + + + + + + + + ); +}; + +const Sound = () => { + const { t } = useTranslation(); + const labelId = "prefSound"; + const sound = useLiveQuery(async () => prefs.sound()); + const handleChange = async (ev) => { + await prefs.setSound(ev.target.value); + await maybeUpdateAccountSettings({ + notification: { + sound: ev.target.value + } + }); + } + if (!sound) { + return null; // While loading + } + let description; + if (sound === "none") { + description = t("prefs_notifications_sound_description_none"); + } else { + description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label }); + } + return ( + +
+ + + + playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}> + + +
+
+ ) +}; + +const MinPriority = () => { + const { t } = useTranslation(); + const labelId = "prefMinPriority"; + const minPriority = useLiveQuery(async () => prefs.minPriority()); + const handleChange = async (ev) => { + await prefs.setMinPriority(ev.target.value); + await maybeUpdateAccountSettings({ + notification: { + min_priority: ev.target.value + } + }); + } + if (!minPriority) { + return null; // While loading + } + const priorities = { + 1: t("priority_min"), + 2: t("priority_low"), + 3: t("priority_default"), + 4: t("priority_high"), + 5: t("priority_max") + } + let description; + if (minPriority === 1) { + description = t("prefs_notifications_min_priority_description_any"); + } else if (minPriority === 5) { + description = t("prefs_notifications_min_priority_description_max"); + } else { + description = t("prefs_notifications_min_priority_description_x_or_higher", { + number: minPriority, + name: priorities[minPriority] + }); + } + return ( + + + + + + ) +}; + +const DeleteAfter = () => { + const { t } = useTranslation(); + const labelId = "prefDeleteAfter"; + const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); + const handleChange = async (ev) => { + await prefs.setDeleteAfter(ev.target.value); + await maybeUpdateAccountSettings({ + notification: { + delete_after: ev.target.value + } + }); + } + if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0" + return null; // While loading + } + const description = (() => { + switch (deleteAfter) { + case 0: return t("prefs_notifications_delete_after_never_description"); + case 10800: return t("prefs_notifications_delete_after_three_hours_description"); + case 86400: return t("prefs_notifications_delete_after_one_day_description"); + case 604800: return t("prefs_notifications_delete_after_one_week_description"); + case 2592000: return t("prefs_notifications_delete_after_one_month_description"); + } + })(); + return ( + + + + + + ) +}; + +const Users = () => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const users = useLiveQuery(() => userManager.all()); + const handleAddClick = () => { + setDialogKey(prev => prev+1); + setDialogOpen(true); + }; + const handleDialogCancel = () => { + setDialogOpen(false); + }; + const handleDialogSubmit = async (user) => { + setDialogOpen(false); + try { + await userManager.save(user); + console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`); + } catch (e) { + console.log(`[Preferences] Error adding user.`, e); + } + }; + return ( + + + + {t("prefs_users_title")} + + + {t("prefs_users_description")} + {session.exists() && <>{" " + t("prefs_users_description_no_sync")}} + + {users?.length > 0 && } + + + + + + + ); +}; + +const UserTable = (props) => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogUser, setDialogUser] = useState(null); + + const handleEditClick = (user) => { + setDialogKey(prev => prev+1); + setDialogUser(user); + setDialogOpen(true); + }; + + const handleDialogCancel = () => { + setDialogOpen(false); + }; + + const handleDialogSubmit = async (user) => { + setDialogOpen(false); + try { + await userManager.save(user); + console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`); + } catch (e) { + console.log(`[Preferences] Error updating user.`, e); + } + }; + + const handleDeleteClick = async (user) => { + try { + await userManager.delete(user.baseUrl); + console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`); + } catch (e) { + console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); + } + }; + + return ( + + + + {t("prefs_users_table_user_header")} + {t("prefs_users_table_base_url_header")} + + + + + {props.users?.map(user => ( + + {user.username} + {user.baseUrl} + + {(!session.exists() || user.baseUrl !== config.base_url) && + <> + handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> + + + handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> + + + + } + {session.exists() && user.baseUrl === config.base_url && + + + + + + + } + + + ))} + + +
+ ); +}; + +const UserDialog = (props) => { + const { t } = useTranslation(); + const [baseUrl, setBaseUrl] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const editMode = props.user !== null; + const addButtonEnabled = (() => { + if (editMode) { + return username.length > 0 && password.length > 0; + } + const baseUrlValid = validUrl(baseUrl); + const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl); + return baseUrlValid + && !baseUrlExists + && username.length > 0 + && password.length > 0; + })(); + const handleSubmit = async () => { + props.onSubmit({ + baseUrl: baseUrl, + username: username, + password: password + }) + }; + useEffect(() => { + if (editMode) { + setBaseUrl(props.user.baseUrl); + setUsername(props.user.username); + setPassword(props.user.password); + } + }, [editMode, props.user]); + return ( + + {editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")} + + {!editMode && setBaseUrl(ev.target.value)} + type="url" + fullWidth + variant="standard" + />} + setUsername(ev.target.value)} + type="text" + fullWidth + variant="standard" + /> + setPassword(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + ); +}; + +const Appearance = () => { + const { t } = useTranslation(); + return ( + + + {t("prefs_appearance_title")} + + + + + + ); +}; + +const Language = () => { + const { t, i18n } = useTranslation(); + const labelId = "prefLanguage"; + const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3); + const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" "); + const lang = i18n.language ?? "en"; + + const handleChange = async (ev) => { + await i18n.changeLanguage(ev.target.value); + await maybeUpdateAccountSettings({ + language: ev.target.value + }); + }; + + // Remember: Flags are not languages. Don't put flags next to the language in the list. + // Languages names from: https://www.omniglot.com/language/names.htm + // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l + + return ( + + + + + + ) +}; + +const Reservations = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + + if (!config.enable_reservations || !session.exists() || !account) { + return <>; + } + const reservations = account.reservations || []; + const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0; + + const handleAddClick = () => { + setDialogKey(prev => prev+1); + setDialogOpen(true); + }; + + return ( + + + + {t("prefs_reservations_title")} + + + {t("prefs_reservations_description")} + + {reservations.length > 0 && } + {limitReached && {t("prefs_reservations_limit_reached")}} + + + + setDialogOpen(false)} + /> + + + ); +}; + +const ReservationsTable = (props) => { + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogReservation, setDialogReservation] = useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const { subscriptions } = useOutletContext(); + const localSubscriptions = (subscriptions?.length > 0) + ? Object.assign(...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s}))) + : []; + + const handleEditClick = (reservation) => { + setDialogKey(prev => prev+1); + setDialogReservation(reservation); + setEditDialogOpen(true); + }; + + const handleDeleteClick = async (reservation) => { + setDialogKey(prev => prev+1); + setDialogReservation(reservation); + setDeleteDialogOpen(true); + }; + + const handleSubscribeClick = async (reservation) => { + await subscribeTopic(config.base_url, reservation.topic); + }; + + return ( + + + + {t("prefs_reservations_table_topic_header")} + {t("prefs_reservations_table_access_header")} + + + + + {props.reservations.map(reservation => ( + + + {reservation.topic} + + + {reservation.everyone === Permission.READ_WRITE && + <> + + {t("prefs_reservations_table_everyone_read_write")} + + } + {reservation.everyone === Permission.READ_ONLY && + <> + + {t("prefs_reservations_table_everyone_read_only")} + + } + {reservation.everyone === Permission.WRITE_ONLY && + <> + + {t("prefs_reservations_table_everyone_write_only")} + + } + {reservation.everyone === Permission.DENY_ALL && + <> + + {t("prefs_reservations_table_everyone_deny_all")} + + } + + + {!localSubscriptions[reservation.topic] && + + } onClick={() => handleSubscribeClick(reservation)} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/> + + } + handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> + + + handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> + + + + + ))} + + setEditDialogOpen(false)} + /> + setDeleteDialogOpen(false)} + /> +
+ ); +}; + +const maybeUpdateAccountSettings = async (payload) => { + if (!session.exists()) { + return; + } + try { + await accountApi.updateSettings(payload); + } catch (e) { + console.log(`[Preferences] Error updating account settings`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } +}; + +export default Preferences; diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx deleted file mode 100644 index 4afc0f8..0000000 --- a/web/src/components/Preferences.jsx +++ /dev/null @@ -1,695 +0,0 @@ -import * as React from "react"; -import { useContext, useEffect, useState } from "react"; -import { - Alert, - CardActions, - CardContent, - Chip, - FormControl, - Select, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Tooltip, - useMediaQuery, - Typography, - IconButton, - Container, - TextField, - MenuItem, - Card, - Button, - Dialog, - DialogTitle, - DialogContent, - DialogActions, -} from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; -import CloseIcon from "@mui/icons-material/Close"; -import PlayArrowIcon from "@mui/icons-material/PlayArrow"; -import { useLiveQuery } from "dexie-react-hooks"; -import { useTranslation } from "react-i18next"; -import { Info } from "@mui/icons-material"; -import { useOutletContext } from "react-router-dom"; -import theme from "./theme"; -import userManager from "../app/UserManager"; -import { playSound, shuffle, sounds, validUrl } from "../app/utils"; -import session from "../app/Session"; -import routes from "./routes"; -import accountApi, { Permission, Role } from "../app/AccountApi"; -import { Pref, PrefGroup } from "./Pref"; -import { AccountContext } from "./App"; -import { Paragraph } from "./styles"; -import prefs from "../app/Prefs"; -import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; -import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; -import { UnauthorizedError } from "../app/errors"; -import { subscribeTopic } from "./SubscribeDialog"; - -const maybeUpdateAccountSettings = async (payload) => { - if (!session.exists()) { - return; - } - try { - await accountApi.updateSettings(payload); - } catch (e) { - console.log(`[Preferences] Error updating account settings`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } -}; - -const Preferences = () => ( - - - - - - - - -); - -const Notifications = () => { - const { t } = useTranslation(); - return ( - - - {t("prefs_notifications_title")} - - - - - - - - ); -}; - -const Sound = () => { - const { t } = useTranslation(); - const labelId = "prefSound"; - const sound = useLiveQuery(async () => prefs.sound()); - const handleChange = async (ev) => { - await prefs.setSound(ev.target.value); - await maybeUpdateAccountSettings({ - notification: { - sound: ev.target.value, - }, - }); - }; - if (!sound) { - return null; // While loading - } - let description; - if (sound === "none") { - description = t("prefs_notifications_sound_description_none"); - } else { - description = t("prefs_notifications_sound_description_some", { - sound: sounds[sound].label, - }); - } - return ( - -
- - - - playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}> - - -
-
- ); -}; - -const MinPriority = () => { - const { t } = useTranslation(); - const labelId = "prefMinPriority"; - const minPriority = useLiveQuery(async () => prefs.minPriority()); - const handleChange = async (ev) => { - await prefs.setMinPriority(ev.target.value); - await maybeUpdateAccountSettings({ - notification: { - min_priority: ev.target.value, - }, - }); - }; - if (!minPriority) { - return null; // While loading - } - const priorities = { - 1: t("priority_min"), - 2: t("priority_low"), - 3: t("priority_default"), - 4: t("priority_high"), - 5: t("priority_max"), - }; - let description; - if (minPriority === 1) { - description = t("prefs_notifications_min_priority_description_any"); - } else if (minPriority === 5) { - description = t("prefs_notifications_min_priority_description_max"); - } else { - description = t("prefs_notifications_min_priority_description_x_or_higher", { - number: minPriority, - name: priorities[minPriority], - }); - } - return ( - - - - - - ); -}; - -const DeleteAfter = () => { - const { t } = useTranslation(); - const labelId = "prefDeleteAfter"; - const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); - const handleChange = async (ev) => { - await prefs.setDeleteAfter(ev.target.value); - await maybeUpdateAccountSettings({ - notification: { - delete_after: ev.target.value, - }, - }); - }; - - if (deleteAfter === null || deleteAfter === undefined) { - // !deleteAfter will not work with "0" - return null; // While loading - } - - const description = (() => { - switch (deleteAfter) { - case 0: - return t("prefs_notifications_delete_after_never_description"); - case 10800: - return t("prefs_notifications_delete_after_three_hours_description"); - case 86400: - return t("prefs_notifications_delete_after_one_day_description"); - case 604800: - return t("prefs_notifications_delete_after_one_week_description"); - case 2592000: - return t("prefs_notifications_delete_after_one_month_description"); - default: - return ""; - } - })(); - - return ( - - - - - - ); -}; - -const Users = () => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const users = useLiveQuery(() => userManager.all()); - const handleAddClick = () => { - setDialogKey((prev) => prev + 1); - setDialogOpen(true); - }; - const handleDialogCancel = () => { - setDialogOpen(false); - }; - const handleDialogSubmit = async (user) => { - setDialogOpen(false); - try { - await userManager.save(user); - console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`); - } catch (e) { - console.log(`[Preferences] Error adding user.`, e); - } - }; - return ( - - - - {t("prefs_users_title")} - - - {t("prefs_users_description")} - {session.exists() && <>{` ${t("prefs_users_description_no_sync")}`}} - - {users?.length > 0 && } - - - - - - - ); -}; - -const UserTable = (props) => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const [dialogUser, setDialogUser] = useState(null); - - const handleEditClick = (user) => { - setDialogKey((prev) => prev + 1); - setDialogUser(user); - setDialogOpen(true); - }; - - const handleDialogCancel = () => { - setDialogOpen(false); - }; - - const handleDialogSubmit = async (user) => { - setDialogOpen(false); - try { - await userManager.save(user); - console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`); - } catch (e) { - console.log(`[Preferences] Error updating user.`, e); - } - }; - - const handleDeleteClick = async (user) => { - try { - await userManager.delete(user.baseUrl); - console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`); - } catch (e) { - console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); - } - }; - - return ( - - - - {t("prefs_users_table_user_header")} - {t("prefs_users_table_base_url_header")} - - - - - {props.users?.map((user) => ( - - - {user.username} - - {user.baseUrl} - - {(!session.exists() || user.baseUrl !== config.base_url) && ( - <> - handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> - - - handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> - - - - )} - {session.exists() && user.baseUrl === config.base_url && ( - - - - - - - - - - - )} - - - ))} - - -
- ); -}; - -const UserDialog = (props) => { - const { t } = useTranslation(); - const [baseUrl, setBaseUrl] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const editMode = props.user !== null; - const addButtonEnabled = (() => { - if (editMode) { - return username.length > 0 && password.length > 0; - } - const baseUrlValid = validUrl(baseUrl); - const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl); - return baseUrlValid && !baseUrlExists && username.length > 0 && password.length > 0; - })(); - const handleSubmit = async () => { - props.onSubmit({ - baseUrl, - username, - password, - }); - }; - useEffect(() => { - if (editMode) { - setBaseUrl(props.user.baseUrl); - setUsername(props.user.username); - setPassword(props.user.password); - } - }, [editMode, props.user]); - return ( - - {editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")} - - {!editMode && ( - setBaseUrl(ev.target.value)} - type="url" - fullWidth - variant="standard" - /> - )} - setUsername(ev.target.value)} - type="text" - fullWidth - variant="standard" - /> - setPassword(ev.target.value)} - fullWidth - variant="standard" - /> - - - - - - - ); -}; - -const Appearance = () => { - const { t } = useTranslation(); - return ( - - - {t("prefs_appearance_title")} - - - - - - ); -}; - -const Language = () => { - const { t, i18n } = useTranslation(); - const labelId = "prefLanguage"; - const lang = i18n.resolvedLanguage ?? "en"; - - // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts. - // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows. - const randomFlags = shuffle([ - "🇬🇧", - "🇺🇸", - "🇪🇸", - "🇫🇷", - "🇧🇬", - "🇨🇿", - "🇩🇪", - "🇵🇱", - "🇺🇦", - "🇨🇳", - "🇮🇹", - "🇭🇺", - "🇧🇷", - "🇳🇱", - "🇮🇩", - "🇯🇵", - "🇷🇺", - "🇹🇷", - ]).slice(0, 3); - const showFlags = !navigator.userAgent.includes("Windows"); - let title = t("prefs_appearance_language_title"); - if (showFlags) { - title += ` ${randomFlags.join(" ")}`; - } - - const handleChange = async (ev) => { - await i18n.changeLanguage(ev.target.value); - await maybeUpdateAccountSettings({ - language: ev.target.value, - }); - }; - - // Remember: Flags are not languages. Don't put flags next to the language in the list. - // Languages names from: https://www.omniglot.com/language/names.htm - // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l - - return ( - - - - - - ); -}; - -const Reservations = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - - if (!config.enable_reservations || !session.exists() || !account) { - return <>; - } - const reservations = account.reservations || []; - const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0; - - const handleAddClick = () => { - setDialogKey((prev) => prev + 1); - setDialogOpen(true); - }; - - return ( - - - - {t("prefs_reservations_title")} - - {t("prefs_reservations_description")} - {reservations.length > 0 && } - {limitReached && {t("prefs_reservations_limit_reached")}} - - - - setDialogOpen(false)} - /> - - - ); -}; - -const ReservationsTable = (props) => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogReservation, setDialogReservation] = useState(null); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const { subscriptions } = useOutletContext(); - const localSubscriptions = - subscriptions?.length > 0 - ? Object.assign({}, ...subscriptions.filter((s) => s.baseUrl === config.base_url).map((s) => ({ [s.topic]: s }))) - : {}; - - const handleEditClick = (reservation) => { - setDialogKey((prev) => prev + 1); - setDialogReservation(reservation); - setEditDialogOpen(true); - }; - - const handleDeleteClick = async (reservation) => { - setDialogKey((prev) => prev + 1); - setDialogReservation(reservation); - setDeleteDialogOpen(true); - }; - - const handleSubscribeClick = async (reservation) => { - await subscribeTopic(config.base_url, reservation.topic); - }; - - return ( - - - - {t("prefs_reservations_table_topic_header")} - {t("prefs_reservations_table_access_header")} - - - - - {props.reservations.map((reservation) => ( - - - {reservation.topic} - - - {reservation.everyone === Permission.READ_WRITE && ( - <> - - {t("prefs_reservations_table_everyone_read_write")} - - )} - {reservation.everyone === Permission.READ_ONLY && ( - <> - - {t("prefs_reservations_table_everyone_read_only")} - - )} - {reservation.everyone === Permission.WRITE_ONLY && ( - <> - - {t("prefs_reservations_table_everyone_write_only")} - - )} - {reservation.everyone === Permission.DENY_ALL && ( - <> - - {t("prefs_reservations_table_everyone_deny_all")} - - )} - - - {!localSubscriptions[reservation.topic] && ( - - } - onClick={() => handleSubscribeClick(reservation)} - label={t("prefs_reservations_table_not_subscribed")} - color="primary" - variant="outlined" - /> - - )} - handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> - - - handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> - - - - - ))} - - setEditDialogOpen(false)} - /> - setDeleteDialogOpen(false)} - /> -
- ); -}; - -export default Preferences; diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js new file mode 100644 index 0000000..bdf6fb6 --- /dev/null +++ b/web/src/components/PublishDialog.js @@ -0,0 +1,740 @@ +import * as React from 'react'; +import {useEffect, useRef, useState} from 'react'; +import theme from "./theme"; +import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material"; +import TextField from "@mui/material/TextField"; +import priority1 from "../img/priority-1.svg"; +import priority2 from "../img/priority-2.svg"; +import priority3 from "../img/priority-3.svg"; +import priority4 from "../img/priority-4.svg"; +import priority5 from "../img/priority-5.svg"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon'; +import {Close} from "@mui/icons-material"; +import MenuItem from "@mui/material/MenuItem"; +import {formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils"; +import Box from "@mui/material/Box"; +import AttachmentIcon from "./AttachmentIcon"; +import DialogFooter from "./DialogFooter"; +import api from "../app/Api"; +import userManager from "../app/UserManager"; +import EmojiPicker from "./EmojiPicker"; +import {Trans, useTranslation} from "react-i18next"; +import session from "../app/Session"; +import routes from "./routes"; +import accountApi from "../app/AccountApi"; +import {UnauthorizedError} from "../app/errors"; + +const PublishDialog = (props) => { + const { t } = useTranslation(); + const [baseUrl, setBaseUrl] = useState(""); + const [topic, setTopic] = useState(""); + const [message, setMessage] = useState(""); + const [messageFocused, setMessageFocused] = useState(true); + const [title, setTitle] = useState(""); + const [tags, setTags] = useState(""); + const [priority, setPriority] = useState(3); + const [clickUrl, setClickUrl] = useState(""); + const [attachUrl, setAttachUrl] = useState(""); + const [attachFile, setAttachFile] = useState(null); + const [filename, setFilename] = useState(""); + const [filenameEdited, setFilenameEdited] = useState(false); + const [email, setEmail] = useState(""); + const [delay, setDelay] = useState(""); + const [publishAnother, setPublishAnother] = useState(false); + + const [showTopicUrl, setShowTopicUrl] = useState(""); + const [showClickUrl, setShowClickUrl] = useState(false); + const [showAttachUrl, setShowAttachUrl] = useState(false); + const [showEmail, setShowEmail] = useState(false); + const [showDelay, setShowDelay] = useState(false); + + const showAttachFile = !!attachFile && !showAttachUrl; + const attachFileInput = useRef(); + const [attachFileError, setAttachFileError] = useState(""); + + const [activeRequest, setActiveRequest] = useState(null); + const [status, setStatus] = useState(""); + const disabled = !!activeRequest; + + const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null); + + const [dropZone, setDropZone] = useState(false); + const [sendButtonEnabled, setSendButtonEnabled] = useState(true); + + const open = !!props.openMode; + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + useEffect(() => { + window.addEventListener('dragenter', () => { + props.onDragEnter(); + setDropZone(true); + }); + }, []); + + useEffect(() => { + setBaseUrl(props.baseUrl); + setTopic(props.topic); + setShowTopicUrl(!props.baseUrl || !props.topic); + setMessageFocused(!!props.topic); // Focus message only if topic is set + }, [props.baseUrl, props.topic]); + + useEffect(() => { + const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError; + setSendButtonEnabled(valid); + }, [baseUrl, topic, attachFileError]); + + useEffect(() => { + setMessage(props.message); + }, [props.message]); + + const updateBaseUrl = (newVal) => { + if (validUrl(newVal)) { + setBaseUrl(newVal.replace(/\/$/, '')); // strip traililng slash after https?:// + } else { + setBaseUrl(newVal); + } + }; + + const handleSubmit = async () => { + const url = new URL(topicUrl(baseUrl, topic)); + if (title.trim()) { + url.searchParams.append("title", title.trim()); + } + if (tags.trim()) { + url.searchParams.append("tags", tags.trim()); + } + if (priority && priority !== 3) { + url.searchParams.append("priority", priority.toString()); + } + if (clickUrl.trim()) { + url.searchParams.append("click", clickUrl.trim()); + } + if (attachUrl.trim()) { + url.searchParams.append("attach", attachUrl.trim()); + } + if (filename.trim()) { + url.searchParams.append("filename", filename.trim()); + } + if (email.trim()) { + url.searchParams.append("email", email.trim()); + } + if (delay.trim()) { + url.searchParams.append("delay", delay.trim()); + } + if (attachFile && message.trim()) { + url.searchParams.append("message", message.replaceAll("\n", "\\n").trim()); + } + const body = (attachFile) ? attachFile : message; + try { + const user = await userManager.get(baseUrl); + const headers = maybeWithAuth({}, user); + const progressFn = (ev) => { + if (ev.loaded > 0 && ev.total > 0) { + setStatus(t("publish_dialog_progress_uploading_detail", { + loaded: formatBytes(ev.loaded), + total: formatBytes(ev.total), + percent: Math.round(ev.loaded * 100.0 / ev.total) + })); + } else { + setStatus(t("publish_dialog_progress_uploading")); + } + }; + const request = api.publishXHR(url, body, headers, progressFn); + setActiveRequest(request); + await request; + if (!publishAnother) { + props.onClose(); + } else { + setStatus(t("publish_dialog_message_published")); + setActiveRequest(null); + } + } catch (e) { + setStatus({e}); + setActiveRequest(null); + } + }; + + const checkAttachmentLimits = async (file) => { + try { + const account = await accountApi.get(); + const fileSizeLimit = account.limits.attachment_file_size ?? 0; + const remainingBytes = account.stats.attachment_total_size_remaining; + const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; + const quotaReached = remainingBytes > 0 && file.size > remainingBytes; + if (fileSizeLimitReached && quotaReached) { + return setAttachFileError(t("publish_dialog_attachment_limits_file_and_quota_reached", { + fileSizeLimit: formatBytes(fileSizeLimit), + remainingBytes: formatBytes(remainingBytes) + })); + } else if (fileSizeLimitReached) { + return setAttachFileError(t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit) })); + } else if (quotaReached) { + return setAttachFileError(t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes) })); + } + setAttachFileError(""); + } catch (e) { + console.log(`[PublishDialog] Retrieving attachment limits failed`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setAttachFileError(""); // Reset error (rely on server-side checking) + } + } + }; + + const handleAttachFileClick = () => { + attachFileInput.current.click(); + }; + + const handleAttachFileChanged = async (ev) => { + await updateAttachFile(ev.target.files[0]); + }; + + const handleAttachFileDrop = async (ev) => { + ev.preventDefault(); + setDropZone(false); + await updateAttachFile(ev.dataTransfer.files[0]); + }; + + const updateAttachFile = async (file) => { + setAttachFile(file); + setFilename(file.name); + props.onResetOpenMode(); + await checkAttachmentLimits(file); + }; + + const handleAttachFileDragLeave = () => { + setDropZone(false); + if (props.openMode === PublishDialog.OPEN_MODE_DRAG) { + props.onClose(); // Only close dialog if it was not open before dragging file in + } + }; + + const handleEmojiClick = (ev) => { + setEmojiPickerAnchorEl(ev.currentTarget); + }; + + const handleEmojiPick = (emoji) => { + setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji); + }; + + const handleEmojiClose = () => { + setEmojiPickerAnchorEl(null); + }; + + const priorities = { + 1: { label: t("publish_dialog_priority_min"), file: priority1 }, + 2: { label: t("publish_dialog_priority_low"), file: priority2 }, + 3: { label: t("publish_dialog_priority_default"), file: priority3 }, + 4: { label: t("publish_dialog_priority_high"), file: priority4 }, + 5: { label: t("publish_dialog_priority_max"), file: priority5 } + }; + + return ( + <> + {dropZone && + } + + {(baseUrl && topic) ? t("publish_dialog_title_topic", { topic: topicShortUrl(baseUrl, topic) }) : t("publish_dialog_title_no_topic")} + + {dropZone && } + {showTopicUrl && + { + setBaseUrl(props.baseUrl); + setTopic(props.topic); + setShowTopicUrl(false); + }}> + updateBaseUrl(ev.target.value)} + disabled={disabled} + type="url" + variant="standard" + sx={{flexGrow: 1, marginRight: 1}} + inputProps={{ + "aria-label": t("publish_dialog_base_url_label") + }} + /> + setTopic(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + autoFocus={!messageFocused} + sx={{flexGrow: 1}} + inputProps={{ + "aria-label": t("publish_dialog_topic_label") + }} + /> + + } + setTitle(ev.target.value)} + disabled={disabled} + type="text" + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("publish_dialog_title_label") + }} + /> + setMessage(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + rows={5} + autoFocus={messageFocused} + fullWidth + multiline + inputProps={{ + "aria-label": t("publish_dialog_message_label") + }} + /> +
+ + + + + setTags(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + sx={{flexGrow: 1, marginRight: 1}} + inputProps={{ + "aria-label": t("publish_dialog_tags_label") + }} + /> + + + + +
+ {showClickUrl && + { + setClickUrl(""); + setShowClickUrl(false); + }}> + setClickUrl(ev.target.value)} + disabled={disabled} + type="url" + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("publish_dialog_click_label") + }} + /> + + } + {showEmail && + { + setEmail(""); + setShowEmail(false); + }}> + setEmail(ev.target.value)} + disabled={disabled} + type="email" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_email_label") + }} + /> + + } + {showAttachUrl && + { + setAttachUrl(""); + setFilename(""); + setFilenameEdited(false); + setShowAttachUrl(false); + }}> + { + const url = ev.target.value; + setAttachUrl(url); + if (!filenameEdited) { + try { + const u = new URL(url); + const parts = u.pathname.split("/"); + if (parts.length > 0) { + setFilename(parts[parts.length-1]); + } + } catch (e) { + // Do nothing + } + } + }} + disabled={disabled} + type="url" + variant="standard" + sx={{flexGrow: 5, marginRight: 1}} + inputProps={{ + "aria-label": t("publish_dialog_attach_label") + }} + /> + { + setFilename(ev.target.value); + setFilenameEdited(true); + }} + disabled={disabled} + type="text" + variant="standard" + sx={{flexGrow: 1}} + inputProps={{ + "aria-label": t("publish_dialog_filename_label") + }} + /> + + } + + {showAttachFile && setFilename(f)} + onClose={() => { + setAttachFile(null); + setAttachFileError(""); + setFilename(""); + }} + />} + {showDelay && + { + setDelay(""); + setShowDelay(false); + }}> + setDelay(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_delay_label") + }} + /> + + } + + {t("publish_dialog_other_features")} + +
+ {!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showDelay && setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showTopicUrl && setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} +
+ + + }} + /> + +
+ + {activeRequest && } + {!activeRequest && + <> + setPublishAnother(ev.target.checked)} + inputProps={{ + "aria-label": t("publish_dialog_checkbox_publish_another") + }} /> + } /> + + + + } + +
+ + ); +}; + +const Row = (props) => { + return ( +
+ {props.children} +
+ ); +}; + +const ClosableRow = (props) => { + const closable = (props.hasOwnProperty("closable")) ? props.closable : true; + return ( + + {props.children} + {closable && + + + + } + + ); +}; + +const DialogIconButton = (props) => { + const sx = props.sx || {}; + return ( + + {props.children} + + ); +}; + +const AttachmentBox = (props) => { + const { t } = useTranslation(); + const file = props.file; + return ( + <> + + {t("publish_dialog_attached_file_title")} + + + + + props.onChangeFilename(ev.target.value)} + disabled={props.disabled} + /> +
+ + {formatBytes(file.size)} + {props.error && + + {" "}({props.error}) + + } + +
+ + + +
+ + ); +}; + +const ExpandingTextField = (props) => { + const invisibleFieldRef = useRef(); + const [textWidth, setTextWidth] = useState(props.minWidth); + const determineTextWidth = () => { + const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect(); + if (!boundingRect) { + return props.minWidth; + } + return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth; + }; + useEffect(() => { + setTextWidth(determineTextWidth() + 5); + }, [props.value]); + return ( + <> + + {props.value} + + + + ) +}; + +const DropArea = (props) => { + const allowDrag = (ev) => { + // This is where we could disallow certain files to be dragged in. + // For now we allow all files. + + ev.dataTransfer.dropEffect = 'copy'; + ev.preventDefault(); + }; + + return ( + + ); +}; + +const DropBox = () => { + const { t } = useTranslation(); + return ( + + + {t("publish_dialog_drop_file_here")} + + + ); +} + +PublishDialog.OPEN_MODE_DEFAULT = "default"; +PublishDialog.OPEN_MODE_DRAG = "drag"; + +export default PublishDialog; diff --git a/web/src/components/PublishDialog.jsx b/web/src/components/PublishDialog.jsx deleted file mode 100644 index eb0af0d..0000000 --- a/web/src/components/PublishDialog.jsx +++ /dev/null @@ -1,913 +0,0 @@ -import * as React from "react"; -import { useContext, useEffect, useRef, useState } from "react"; -import { - Checkbox, - Chip, - FormControl, - FormControlLabel, - InputLabel, - Link, - Select, - Tooltip, - useMediaQuery, - TextField, - Dialog, - DialogTitle, - DialogContent, - Button, - Typography, - IconButton, - MenuItem, - Box, -} from "@mui/material"; -import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon"; -import { Close } from "@mui/icons-material"; -import { Trans, useTranslation } from "react-i18next"; -import priority1 from "../img/priority-1.svg"; -import priority2 from "../img/priority-2.svg"; -import priority3 from "../img/priority-3.svg"; -import priority4 from "../img/priority-4.svg"; -import priority5 from "../img/priority-5.svg"; -import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils"; -import AttachmentIcon from "./AttachmentIcon"; -import DialogFooter from "./DialogFooter"; -import api from "../app/Api"; -import userManager from "../app/UserManager"; -import EmojiPicker from "./EmojiPicker"; -import theme from "./theme"; -import session from "../app/Session"; -import routes from "./routes"; -import accountApi from "../app/AccountApi"; -import { UnauthorizedError } from "../app/errors"; -import { AccountContext } from "./App"; - -const PublishDialog = (props) => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [baseUrl, setBaseUrl] = useState(""); - const [topic, setTopic] = useState(""); - const [message, setMessage] = useState(""); - const [messageFocused, setMessageFocused] = useState(true); - const [title, setTitle] = useState(""); - const [tags, setTags] = useState(""); - const [priority, setPriority] = useState(3); - const [clickUrl, setClickUrl] = useState(""); - const [attachUrl, setAttachUrl] = useState(""); - const [attachFile, setAttachFile] = useState(null); - const [filename, setFilename] = useState(""); - const [filenameEdited, setFilenameEdited] = useState(false); - const [email, setEmail] = useState(""); - const [call, setCall] = useState(""); - const [delay, setDelay] = useState(""); - const [publishAnother, setPublishAnother] = useState(false); - - const [showTopicUrl, setShowTopicUrl] = useState(""); - const [showClickUrl, setShowClickUrl] = useState(false); - const [showAttachUrl, setShowAttachUrl] = useState(false); - const [showEmail, setShowEmail] = useState(false); - const [showCall, setShowCall] = useState(false); - const [showDelay, setShowDelay] = useState(false); - - const showAttachFile = !!attachFile && !showAttachUrl; - const attachFileInput = useRef(); - const [attachFileError, setAttachFileError] = useState(""); - - const [activeRequest, setActiveRequest] = useState(null); - const [status, setStatus] = useState(""); - const disabled = !!activeRequest; - - const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null); - - const [dropZone, setDropZone] = useState(false); - const [sendButtonEnabled, setSendButtonEnabled] = useState(true); - - const open = !!props.openMode; - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - - useEffect(() => { - window.addEventListener("dragenter", () => { - props.onDragEnter(); - setDropZone(true); - }); - }, []); - - useEffect(() => { - setBaseUrl(props.baseUrl); - setTopic(props.topic); - setShowTopicUrl(!props.baseUrl || !props.topic); - setMessageFocused(!!props.topic); // Focus message only if topic is set - }, [props.baseUrl, props.topic]); - - useEffect(() => { - const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError; - setSendButtonEnabled(valid); - }, [baseUrl, topic, attachFileError]); - - useEffect(() => { - setMessage(props.message); - }, [props.message]); - - const updateBaseUrl = (newVal) => { - if (validUrl(newVal)) { - setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?:// - } else { - setBaseUrl(newVal); - } - }; - - const handleSubmit = async () => { - const url = new URL(topicUrl(baseUrl, topic)); - if (title.trim()) { - url.searchParams.append("title", title.trim()); - } - if (tags.trim()) { - url.searchParams.append("tags", tags.trim()); - } - if (priority && priority !== 3) { - url.searchParams.append("priority", priority.toString()); - } - if (clickUrl.trim()) { - url.searchParams.append("click", clickUrl.trim()); - } - if (attachUrl.trim()) { - url.searchParams.append("attach", attachUrl.trim()); - } - if (filename.trim()) { - url.searchParams.append("filename", filename.trim()); - } - if (email.trim()) { - url.searchParams.append("email", email.trim()); - } - if (call.trim()) { - url.searchParams.append("call", call.trim()); - } - if (delay.trim()) { - url.searchParams.append("delay", delay.trim()); - } - if (attachFile && message.trim()) { - url.searchParams.append("message", message.replaceAll("\n", "\\n").trim()); - } - const body = attachFile || message; - try { - const user = await userManager.get(baseUrl); - const headers = maybeWithAuth({}, user); - const progressFn = (ev) => { - if (ev.loaded > 0 && ev.total > 0) { - setStatus( - t("publish_dialog_progress_uploading_detail", { - loaded: formatBytes(ev.loaded), - total: formatBytes(ev.total), - percent: Math.round((ev.loaded * 100.0) / ev.total), - }) - ); - } else { - setStatus(t("publish_dialog_progress_uploading")); - } - }; - const request = api.publishXHR(url, body, headers, progressFn); - setActiveRequest(request); - await request; - if (!publishAnother) { - props.onClose(); - } else { - setStatus(t("publish_dialog_message_published")); - setActiveRequest(null); - } - } catch (e) { - setStatus({e}); - setActiveRequest(null); - } - }; - - const checkAttachmentLimits = async (file) => { - try { - const apiAccount = await accountApi.get(); - const fileSizeLimit = apiAccount.limits.attachment_file_size ?? 0; - const remainingBytes = apiAccount.stats.attachment_total_size_remaining; - const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; - const quotaReached = remainingBytes > 0 && file.size > remainingBytes; - if (fileSizeLimitReached && quotaReached) { - setAttachFileError( - t("publish_dialog_attachment_limits_file_and_quota_reached", { - fileSizeLimit: formatBytes(fileSizeLimit), - remainingBytes: formatBytes(remainingBytes), - }) - ); - } else if (fileSizeLimitReached) { - setAttachFileError( - t("publish_dialog_attachment_limits_file_reached", { - fileSizeLimit: formatBytes(fileSizeLimit), - }) - ); - } else if (quotaReached) { - setAttachFileError( - t("publish_dialog_attachment_limits_quota_reached", { - remainingBytes: formatBytes(remainingBytes), - }) - ); - } else { - setAttachFileError(""); - } - } catch (e) { - console.log(`[PublishDialog] Retrieving attachment limits failed`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setAttachFileError(""); // Reset error (rely on server-side checking) - } - } - }; - - const handleAttachFileClick = () => { - attachFileInput.current.click(); - }; - - const updateAttachFile = async (file) => { - setAttachFile(file); - setFilename(file.name); - props.onResetOpenMode(); - await checkAttachmentLimits(file); - }; - - const handleAttachFileChanged = async (ev) => { - await updateAttachFile(ev.target.files[0]); - }; - - const handleAttachFileDrop = async (ev) => { - ev.preventDefault(); - setDropZone(false); - await updateAttachFile(ev.dataTransfer.files[0]); - }; - - const handleAttachFileDragLeave = () => { - setDropZone(false); - if (props.openMode === PublishDialog.OPEN_MODE_DRAG) { - props.onClose(); // Only close dialog if it was not open before dragging file in - } - }; - - const handleEmojiClick = (ev) => { - setEmojiPickerAnchorEl(ev.currentTarget); - }; - - const handleEmojiPick = (emoji) => { - setTags((prevTags) => (prevTags.trim() ? `${prevTags.trim()}, ${emoji}` : emoji)); - }; - - const handleEmojiClose = () => { - setEmojiPickerAnchorEl(null); - }; - - const priorities = { - 1: { label: t("publish_dialog_priority_min"), file: priority1 }, - 2: { label: t("publish_dialog_priority_low"), file: priority2 }, - 3: { label: t("publish_dialog_priority_default"), file: priority3 }, - 4: { label: t("publish_dialog_priority_high"), file: priority4 }, - 5: { label: t("publish_dialog_priority_max"), file: priority5 }, - }; - - return ( - <> - {dropZone && } - - - {baseUrl && topic - ? t("publish_dialog_title_topic", { - topic: topicShortUrl(baseUrl, topic), - }) - : t("publish_dialog_title_no_topic")} - - - {dropZone && } - {showTopicUrl && ( - { - setBaseUrl(props.baseUrl); - setTopic(props.topic); - setShowTopicUrl(false); - }} - > - updateBaseUrl(ev.target.value)} - disabled={disabled} - type="url" - variant="standard" - sx={{ flexGrow: 1, marginRight: 1 }} - inputProps={{ - "aria-label": t("publish_dialog_base_url_label"), - }} - /> - setTopic(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - autoFocus={!messageFocused} - sx={{ flexGrow: 1 }} - inputProps={{ - "aria-label": t("publish_dialog_topic_label"), - }} - /> - - )} - setTitle(ev.target.value)} - disabled={disabled} - type="text" - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("publish_dialog_title_label"), - }} - /> - setMessage(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - rows={5} - autoFocus={messageFocused} - fullWidth - multiline - inputProps={{ - "aria-label": t("publish_dialog_message_label"), - }} - /> -
- - - - - setTags(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - sx={{ flexGrow: 1, marginRight: 1 }} - inputProps={{ - "aria-label": t("publish_dialog_tags_label"), - }} - /> - - - - -
- {showClickUrl && ( - { - setClickUrl(""); - setShowClickUrl(false); - }} - > - setClickUrl(ev.target.value)} - disabled={disabled} - type="url" - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("publish_dialog_click_label"), - }} - /> - - )} - {showEmail && ( - { - setEmail(""); - setShowEmail(false); - }} - > - setEmail(ev.target.value)} - disabled={disabled} - type="email" - variant="standard" - fullWidth - inputProps={{ - "aria-label": t("publish_dialog_email_label"), - }} - /> - - )} - {showCall && ( - { - setCall(""); - setShowCall(false); - }} - > - - - - - - )} - {showAttachUrl && ( - { - setAttachUrl(""); - setFilename(""); - setFilenameEdited(false); - setShowAttachUrl(false); - }} - > - { - const url = ev.target.value; - setAttachUrl(url); - if (!filenameEdited) { - try { - const u = new URL(url); - const parts = u.pathname.split("/"); - if (parts.length > 0) { - setFilename(parts[parts.length - 1]); - } - } catch (e) { - // Do nothing - } - } - }} - disabled={disabled} - type="url" - variant="standard" - sx={{ flexGrow: 5, marginRight: 1 }} - inputProps={{ - "aria-label": t("publish_dialog_attach_label"), - }} - /> - { - setFilename(ev.target.value); - setFilenameEdited(true); - }} - disabled={disabled} - type="text" - variant="standard" - sx={{ flexGrow: 1 }} - inputProps={{ - "aria-label": t("publish_dialog_filename_label"), - }} - /> - - )} - - {showAttachFile && ( - setFilename(f)} - onClose={() => { - setAttachFile(null); - setAttachFileError(""); - setFilename(""); - }} - /> - )} - {showDelay && ( - { - setDelay(""); - setShowDelay(false); - }} - > - setDelay(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - fullWidth - inputProps={{ - "aria-label": t("publish_dialog_delay_label"), - }} - /> - - )} - - {t("publish_dialog_other_features")} - -
- {!showClickUrl && ( - setShowClickUrl(true)} - sx={{ marginRight: 1, marginBottom: 1 }} - /> - )} - {!showEmail && ( - setShowEmail(true)} - sx={{ marginRight: 1, marginBottom: 1 }} - /> - )} - {account?.phone_numbers?.length > 0 && !showCall && ( - { - setShowCall(true); - setCall(account.phone_numbers[0]); - }} - sx={{ marginRight: 1, marginBottom: 1 }} - /> - )} - {!showAttachUrl && !showAttachFile && ( - setShowAttachUrl(true)} - sx={{ marginRight: 1, marginBottom: 1 }} - /> - )} - {!showAttachFile && !showAttachUrl && ( - handleAttachFileClick()} - sx={{ marginRight: 1, marginBottom: 1 }} - /> - )} - {!showDelay && ( - setShowDelay(true)} - sx={{ marginRight: 1, marginBottom: 1 }} - /> - )} - {!showTopicUrl && ( - setShowTopicUrl(true)} - sx={{ marginRight: 1, marginBottom: 1 }} - /> - )} - {account && !account?.phone_numbers && ( - - - - - - )} -
- - , - }} - /> - -
- - {activeRequest && } - {!activeRequest && ( - <> - setPublishAnother(ev.target.checked)} - inputProps={{ - "aria-label": t("publish_dialog_checkbox_publish_another"), - }} - /> - } - /> - - - - )} - -
- - ); -}; - -const Row = (props) => ( -
- {props.children} -
-); - -const ClosableRow = (props) => { - const closable = props.closable !== undefined ? props.closable : true; - return ( - - {props.children} - {closable && ( - - - - )} - - ); -}; - -const DialogIconButton = (props) => { - const sx = props.sx || {}; - return ( - - {props.children} - - ); -}; - -const AttachmentBox = (props) => { - const { t } = useTranslation(); - const { file } = props; - return ( - <> - - {t("publish_dialog_attached_file_title")} - - - - - props.onChangeFilename(ev.target.value)} - disabled={props.disabled} - /> -
- - {formatBytes(file.size)} - {props.error && ( - - {" "} - ({props.error}) - - )} - -
- - - -
- - ); -}; - -const ExpandingTextField = (props) => { - const invisibleFieldRef = useRef(); - const [textWidth, setTextWidth] = useState(props.minWidth); - const determineTextWidth = () => { - const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect(); - if (!boundingRect) { - return props.minWidth; - } - return boundingRect.width >= props.minWidth ? Math.round(boundingRect.width) : props.minWidth; - }; - useEffect(() => { - setTextWidth(determineTextWidth() + 5); - }, [props.value]); - return ( - <> - - {props.value} - - - - ); -}; - -const DropArea = (props) => { - const allowDrag = (ev) => { - // This is where we could disallow certain files to be dragged in. - // For now we allow all files. - - // eslint-disable-next-line no-param-reassign - ev.dataTransfer.dropEffect = "copy"; - ev.preventDefault(); - }; - - return ( - - ); -}; - -const DropBox = () => { - const { t } = useTranslation(); - return ( - - - {t("publish_dialog_drop_file_here")} - - - ); -}; - -PublishDialog.OPEN_MODE_DEFAULT = "default"; -PublishDialog.OPEN_MODE_DRAG = "drag"; - -export default PublishDialog; diff --git a/web/src/components/ReserveDialogs.js b/web/src/components/ReserveDialogs.js new file mode 100644 index 0000000..7a6a044 --- /dev/null +++ b/web/src/components/ReserveDialogs.js @@ -0,0 +1,199 @@ +import * as React from 'react'; +import {useState} from 'react'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import {Alert, FormControl, Select, useMediaQuery} from "@mui/material"; +import theme from "./theme"; +import {validTopic} from "../app/utils"; +import DialogFooter from "./DialogFooter"; +import {useTranslation} from "react-i18next"; +import session from "../app/Session"; +import routes from "./routes"; +import accountApi, {Permission} from "../app/AccountApi"; +import ReserveTopicSelect from "./ReserveTopicSelect"; +import MenuItem from "@mui/material/MenuItem"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import {Check, DeleteForever} from "@mui/icons-material"; +import {TopicReservedError, UnauthorizedError} from "../app/errors"; + +export const ReserveAddDialog = (props) => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [topic, setTopic] = useState(props.topic || ""); + const [everyone, setEveryone] = useState(Permission.DENY_ALL); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const allowTopicEdit = !props.topic; + const alreadyReserved = props.reservations.filter(r => r.topic === topic).length > 0; + const submitButtonEnabled = validTopic(topic) && !alreadyReserved; + + const handleSubmit = async () => { + try { + await accountApi.upsertReservation(topic, everyone); + console.debug(`[ReserveAddDialog] Added reservation for topic ${t}: ${everyone}`); + } catch (e) { + console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else if (e instanceof TopicReservedError) { + setError(t("subscribe_dialog_error_topic_already_reserved")); + return; + } else { + setError(e.message); + return; + } + } + props.onClose(); + }; + + return ( + + {t("prefs_reservations_dialog_title_add")} + + + {t("prefs_reservations_dialog_description")} + + {allowTopicEdit && setTopic(ev.target.value)} + type="url" + fullWidth + variant="standard" + />} + + + + + + + + ); +}; + +export const ReserveEditDialog = (props) => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const handleSubmit = async () => { + try { + await accountApi.upsertReservation(props.reservation.topic, everyone); + console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`); + } catch (e) { + console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; + } + } + props.onClose(); + }; + + return ( + + {t("prefs_reservations_dialog_title_edit")} + + + {t("prefs_reservations_dialog_description")} + + + + + + + + + ); +}; + +export const ReserveDeleteDialog = (props) => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [deleteMessages, setDeleteMessages] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const handleSubmit = async () => { + try { + await accountApi.deleteReservation(props.topic, deleteMessages); + console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`); + } catch (e) { + console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; + } + } + props.onClose(); + }; + + return ( + + {t("prefs_reservations_dialog_title_delete")} + + + {t("reservation_delete_dialog_description")} + + + + + {!deleteMessages && + + {t("reservation_delete_dialog_action_keep_description")} + + } + {deleteMessages && + + {t("reservation_delete_dialog_action_delete_description")} + + } + + + + + + + ); +}; + diff --git a/web/src/components/ReserveDialogs.jsx b/web/src/components/ReserveDialogs.jsx deleted file mode 100644 index 3dc370e..0000000 --- a/web/src/components/ReserveDialogs.jsx +++ /dev/null @@ -1,199 +0,0 @@ -import * as React from "react"; -import { useState } from "react"; -import { - Button, - TextField, - Dialog, - DialogContent, - DialogContentText, - DialogTitle, - Alert, - FormControl, - Select, - useMediaQuery, - MenuItem, - ListItemIcon, - ListItemText, -} from "@mui/material"; -import { useTranslation } from "react-i18next"; -import { Check, DeleteForever } from "@mui/icons-material"; -import theme from "./theme"; -import { validTopic } from "../app/utils"; -import DialogFooter from "./DialogFooter"; -import session from "../app/Session"; -import routes from "./routes"; -import accountApi, { Permission } from "../app/AccountApi"; -import ReserveTopicSelect from "./ReserveTopicSelect"; -import { TopicReservedError, UnauthorizedError } from "../app/errors"; - -export const ReserveAddDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [topic, setTopic] = useState(props.topic || ""); - const [everyone, setEveryone] = useState(Permission.DENY_ALL); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const allowTopicEdit = !props.topic; - const alreadyReserved = props.reservations.filter((r) => r.topic === topic).length > 0; - const submitButtonEnabled = validTopic(topic) && !alreadyReserved; - - const handleSubmit = async () => { - try { - await accountApi.upsertReservation(topic, everyone); - console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`); - } catch (e) { - console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else if (e instanceof TopicReservedError) { - setError(t("subscribe_dialog_error_topic_already_reserved")); - return; - } else { - setError(e.message); - return; - } - } - props.onClose(); - }; - - return ( - - {t("prefs_reservations_dialog_title_add")} - - {t("prefs_reservations_dialog_description")} - {allowTopicEdit && ( - setTopic(ev.target.value)} - type="url" - fullWidth - variant="standard" - /> - )} - - - - - - - - ); -}; - -export const ReserveEditDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - - const handleSubmit = async () => { - try { - await accountApi.upsertReservation(props.reservation.topic, everyone); - console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`); - } catch (e) { - console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - return; - } - } - props.onClose(); - }; - - return ( - - {t("prefs_reservations_dialog_title_edit")} - - {t("prefs_reservations_dialog_description")} - - - - - - - - ); -}; - -export const ReserveDeleteDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [deleteMessages, setDeleteMessages] = useState(false); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - - const handleSubmit = async () => { - try { - await accountApi.deleteReservation(props.topic, deleteMessages); - console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`); - } catch (e) { - console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - return; - } - } - props.onClose(); - }; - - return ( - - {t("prefs_reservations_dialog_title_delete")} - - {t("reservation_delete_dialog_description")} - - - - {!deleteMessages && ( - - {t("reservation_delete_dialog_action_keep_description")} - - )} - {deleteMessages && ( - - {t("reservation_delete_dialog_action_delete_description")} - - )} - - - - - - - ); -}; diff --git a/web/src/components/ReserveIcons.js b/web/src/components/ReserveIcons.js new file mode 100644 index 0000000..0d7b05b --- /dev/null +++ b/web/src/components/ReserveIcons.js @@ -0,0 +1,46 @@ +import * as React from 'react'; +import {Lock, Public} from "@mui/icons-material"; +import Box from "@mui/material/Box"; + +export const PermissionReadWrite = React.forwardRef((props, ref) => { + return ; +}); + +export const PermissionDenyAll = React.forwardRef((props, ref) => { + return ; +}); + +export const PermissionRead = React.forwardRef((props, ref) => { + return ; +}); + +export const PermissionWrite = React.forwardRef((props, ref) => { + return ; +}); + +const PermissionInternal = React.forwardRef((props, ref) => { + const size = props.size ?? "medium"; + const Icon = props.icon; + return ( + + + {props.text && + + {props.text} + + } + + ); +}); diff --git a/web/src/components/ReserveIcons.jsx b/web/src/components/ReserveIcons.jsx deleted file mode 100644 index 95f6f47..0000000 --- a/web/src/components/ReserveIcons.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from "react"; -import { Lock, Public } from "@mui/icons-material"; -import { Box } from "@mui/material"; - -export const PermissionReadWrite = React.forwardRef((props, ref) => ); - -export const PermissionDenyAll = React.forwardRef((props, ref) => ); - -export const PermissionRead = React.forwardRef((props, ref) => ); - -export const PermissionWrite = React.forwardRef((props, ref) => ); - -const PermissionInternal = React.forwardRef((props, ref) => { - const size = props.size ?? "medium"; - const Icon = props.icon; - return ( - - - {props.text && ( - - {props.text} - - )} - - ); -}); diff --git a/web/src/components/ReserveTopicSelect.js b/web/src/components/ReserveTopicSelect.js new file mode 100644 index 0000000..e5daf69 --- /dev/null +++ b/web/src/components/ReserveTopicSelect.js @@ -0,0 +1,49 @@ +import * as React from 'react'; +import {FormControl, Select} from "@mui/material"; +import {useTranslation} from "react-i18next"; +import MenuItem from "@mui/material/MenuItem"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; +import {Permission} from "../app/AccountApi"; + +const ReserveTopicSelect = (props) => { + const { t } = useTranslation(); + const sx = props.sx || {}; + return ( + + + + ); +}; + +export default ReserveTopicSelect; diff --git a/web/src/components/ReserveTopicSelect.jsx b/web/src/components/ReserveTopicSelect.jsx deleted file mode 100644 index 39ae5df..0000000 --- a/web/src/components/ReserveTopicSelect.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import * as React from "react"; -import { FormControl, Select, MenuItem, ListItemIcon, ListItemText } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; -import { Permission } from "../app/AccountApi"; - -const ReserveTopicSelect = (props) => { - const { t } = useTranslation(); - const sx = props.sx || {}; - return ( - - - - ); -}; - -export default ReserveTopicSelect; diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js new file mode 100644 index 0000000..856ce8f --- /dev/null +++ b/web/src/components/Signup.js @@ -0,0 +1,158 @@ +import * as React from 'react'; +import {useState} from 'react'; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; +import Box from "@mui/material/Box"; +import routes from "./routes"; +import session from "../app/Session"; +import Typography from "@mui/material/Typography"; +import {NavLink} from "react-router-dom"; +import AvatarBox from "./AvatarBox"; +import {useTranslation} from "react-i18next"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import accountApi from "../app/AccountApi"; +import {InputAdornment} from "@mui/material"; +import IconButton from "@mui/material/IconButton"; +import {Visibility, VisibilityOff} from "@mui/icons-material"; +import {AccountCreateLimitReachedError, UserExistsError} from "../app/errors"; + +const Signup = () => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + const user = { username, password }; + try { + await accountApi.create(user.username, user.password); + const token = await accountApi.login(user); + console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); + session.store(user.username, token); + window.location.href = routes.app; + } catch (e) { + console.log(`[Signup] Signup for user ${user.username} failed`, e); + if (e instanceof UserExistsError) { + setError(t("signup_error_username_taken", { username: e.username })); + } else if ((e instanceof AccountCreateLimitReachedError)) { + setError(t("signup_error_creation_limit_reached")); + } else { + setError(e.message); + } + } + }; + + if (!config.enable_signup) { + return ( + + {t("signup_disabled")} + + ); + } + + return ( + + + {t("signup_title")} + + + setUsername(ev.target.value.trim())} + autoFocus + /> + setPassword(ev.target.value.trim())} + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + onMouseDown={(ev) => ev.preventDefault()} + edge="end" + > + {showPassword ? : } + + + ) + }} + /> + setConfirm(ev.target.value.trim())} + InputProps={{ + endAdornment: ( + + setShowConfirm(!showConfirm)} + onMouseDown={(ev) => ev.preventDefault()} + edge="end" + > + {showConfirm ? : } + + + ) + }} + /> + + {error && + + + {error} + + } + + {config.enable_login && + + + {t("signup_already_have_account")} + + + } + + ); +} + +export default Signup; diff --git a/web/src/components/Signup.jsx b/web/src/components/Signup.jsx deleted file mode 100644 index 3b82cd6..0000000 --- a/web/src/components/Signup.jsx +++ /dev/null @@ -1,153 +0,0 @@ -import * as React from "react"; -import { useState } from "react"; -import { TextField, Button, Box, Typography, InputAdornment, IconButton } from "@mui/material"; -import { NavLink } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import WarningAmberIcon from "@mui/icons-material/WarningAmber"; -import { Visibility, VisibilityOff } from "@mui/icons-material"; -import accountApi from "../app/AccountApi"; -import AvatarBox from "./AvatarBox"; -import session from "../app/Session"; -import routes from "./routes"; -import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors"; - -const Signup = () => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [confirm, setConfirm] = useState(""); - const [showPassword, setShowPassword] = useState(false); - const [showConfirm, setShowConfirm] = useState(false); - - const handleSubmit = async (event) => { - event.preventDefault(); - const user = { username, password }; - try { - await accountApi.create(user.username, user.password); - const token = await accountApi.login(user); - console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); - session.store(user.username, token); - window.location.href = routes.app; - } catch (e) { - console.log(`[Signup] Signup for user ${user.username} failed`, e); - if (e instanceof UserExistsError) { - setError(t("signup_error_username_taken", { username: e.username })); - } else if (e instanceof AccountCreateLimitReachedError) { - setError(t("signup_error_creation_limit_reached")); - } else { - setError(e.message); - } - } - }; - - if (!config.enable_signup) { - return ( - - {t("signup_disabled")} - - ); - } - - return ( - - {t("signup_title")} - - setUsername(ev.target.value.trim())} - autoFocus - /> - setPassword(ev.target.value.trim())} - InputProps={{ - endAdornment: ( - - setShowPassword(!showPassword)} - onMouseDown={(ev) => ev.preventDefault()} - edge="end" - > - {showPassword ? : } - - - ), - }} - /> - setConfirm(ev.target.value.trim())} - InputProps={{ - endAdornment: ( - - setShowConfirm(!showConfirm)} - onMouseDown={(ev) => ev.preventDefault()} - edge="end" - > - {showConfirm ? : } - - - ), - }} - /> - - {error && ( - - - {error} - - )} - - {config.enable_login && ( - - - {t("signup_already_have_account")} - - - )} - - ); -}; - -export default Signup; diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js new file mode 100644 index 0000000..7ea0052 --- /dev/null +++ b/web/src/components/SubscribeDialog.js @@ -0,0 +1,313 @@ +import * as React from 'react'; +import {useContext, useState} from 'react'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material"; +import theme from "./theme"; +import api from "../app/Api"; +import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; +import userManager from "../app/UserManager"; +import subscriptionManager from "../app/SubscriptionManager"; +import poller from "../app/Poller"; +import DialogFooter from "./DialogFooter"; +import {useTranslation} from "react-i18next"; +import session from "../app/Session"; +import routes from "./routes"; +import accountApi, {Permission, Role} from "../app/AccountApi"; +import ReserveTopicSelect from "./ReserveTopicSelect"; +import {AccountContext} from "./App"; +import {TopicReservedError, UnauthorizedError} from "../app/errors"; +import {ReserveLimitChip} from "./SubscriptionPopup"; + +const publicBaseUrl = "https://ntfy.sh"; + +const SubscribeDialog = (props) => { + const [baseUrl, setBaseUrl] = useState(""); + const [topic, setTopic] = useState(""); + const [showLoginPage, setShowLoginPage] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const handleSuccess = async () => { + console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); + const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url; + const subscription = await subscribeTopic(actualBaseUrl, topic); + poller.pollInBackground(subscription); // Dangle! + props.onSuccess(subscription); + } + + return ( + + {!showLoginPage && setShowLoginPage(true)} + onSuccess={handleSuccess} + />} + {showLoginPage && setShowLoginPage(false)} + onSuccess={handleSuccess} + />} + + ); +}; + +const SubscribePage = (props) => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [error, setError] = useState(""); + const [reserveTopicVisible, setReserveTopicVisible] = useState(false); + const [anotherServerVisible, setAnotherServerVisible] = useState(false); + const [everyone, setEveryone] = useState(Permission.DENY_ALL); + const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url; + const topic = props.topic; + const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic)); + const existingBaseUrls = Array + .from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)])) + .filter(s => s !== config.base_url); + const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account); + const reserveTopicEnabled = session.exists() && account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0; + + const handleSubscribe = async () => { + const user = await userManager.get(baseUrl); // May be undefined + const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); + + // Check read access to topic + const success = await api.topicAuth(baseUrl, topic, user); + if (!success) { + console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); + if (user) { + setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); + return; + } else { + props.onNeedsLogin(); + return; + } + } + + // Reserve topic (if requested) + if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) { + console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`); + try { + await accountApi.upsertReservation(topic, everyone); + } catch (e) { + console.log(`[SubscribeDialog] Error reserving topic`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else if (e instanceof TopicReservedError) { + setError(t("subscribe_dialog_error_topic_already_reserved")); + return; + } + } + } + + console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); + props.onSuccess(); + }; + + const handleUseAnotherChanged = (e) => { + props.setBaseUrl(""); + setAnotherServerVisible(e.target.checked); + }; + + const subscribeButtonEnabled = (() => { + if (anotherServerVisible) { + const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); + return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; + } else { + const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic)); + return validTopic(topic) && !isExistingTopicUrl; + } + })(); + + const updateBaseUrl = (ev, newVal) => { + if (validUrl(newVal)) { + props.setBaseUrl(newVal.replace(/\/$/, '')); // strip trailing slash after https?:// + } else { + props.setBaseUrl(newVal); + } + }; + + return ( + <> + {t("subscribe_dialog_subscribe_title")} + + + {t("subscribe_dialog_subscribe_description")} + +
+ props.setTopic(ev.target.value)} + type="text" + fullWidth + variant="standard" + inputProps={{ + maxLength: 64, + "aria-label": t("subscribe_dialog_subscribe_topic_placeholder") + }} + /> + +
+ {showReserveTopicCheckbox && + + setReserveTopicVisible(ev.target.checked)} + inputProps={{ + "aria-label": t("reserve_dialog_checkbox_label") + }} + /> + } + label={ + <> + {t("reserve_dialog_checkbox_label")} + + + } + /> + {reserveTopicVisible && + + } + + } + {!reserveTopicVisible && + + + } + label={t("subscribe_dialog_subscribe_use_another_label")}/> + {anotherServerVisible && + + } + />} + + } +
+ + + + + + ); +}; + +const LoginPage = (props) => { + const { t } = useTranslation(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url; + const topic = props.topic; + + const handleLogin = async () => { + const user = {baseUrl, username, password}; + const success = await api.topicAuth(baseUrl, topic, user); + if (!success) { + console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); + setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); + return; + } + console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); + await userManager.save(user); + props.onSuccess(); + }; + + return ( + <> + {t("subscribe_dialog_login_title")} + + + {t("subscribe_dialog_login_description")} + + setUsername(ev.target.value)} + type="text" + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("subscribe_dialog_login_username_label") + }} + /> + setPassword(ev.target.value)} + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("subscribe_dialog_login_password_label") + }} + /> + + + + + + + ); +}; + +export const subscribeTopic = async (baseUrl, topic) => { + const subscription = await subscriptionManager.add(baseUrl, topic); + if (session.exists()) { + try { + await accountApi.addSubscription(baseUrl, topic); + } catch (e) { + console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } + } + return subscription; +}; + +export default SubscribeDialog; diff --git a/web/src/components/SubscribeDialog.jsx b/web/src/components/SubscribeDialog.jsx deleted file mode 100644 index 0f1cec1..0000000 --- a/web/src/components/SubscribeDialog.jsx +++ /dev/null @@ -1,320 +0,0 @@ -import * as React from "react"; -import { useContext, useState } from "react"; -import { - Button, - TextField, - Dialog, - DialogContent, - DialogContentText, - DialogTitle, - Autocomplete, - Checkbox, - FormControlLabel, - FormGroup, - useMediaQuery, -} from "@mui/material"; -import { useTranslation } from "react-i18next"; -import theme from "./theme"; -import api from "../app/Api"; -import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils"; -import userManager from "../app/UserManager"; -import subscriptionManager from "../app/SubscriptionManager"; -import poller from "../app/Poller"; -import DialogFooter from "./DialogFooter"; -import session from "../app/Session"; -import routes from "./routes"; -import accountApi, { Permission, Role } from "../app/AccountApi"; -import ReserveTopicSelect from "./ReserveTopicSelect"; -import { AccountContext } from "./App"; -import { TopicReservedError, UnauthorizedError } from "../app/errors"; -import { ReserveLimitChip } from "./SubscriptionPopup"; - -const publicBaseUrl = "https://ntfy.sh"; - -export const subscribeTopic = async (baseUrl, topic) => { - const subscription = await subscriptionManager.add(baseUrl, topic); - if (session.exists()) { - try { - await accountApi.addSubscription(baseUrl, topic); - } catch (e) { - console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } - } - return subscription; -}; - -const SubscribeDialog = (props) => { - const [baseUrl, setBaseUrl] = useState(""); - const [topic, setTopic] = useState(""); - const [showLoginPage, setShowLoginPage] = useState(false); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - - const handleSuccess = async () => { - console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); - const actualBaseUrl = baseUrl || config.base_url; - const subscription = await subscribeTopic(actualBaseUrl, topic); - poller.pollInBackground(subscription); // Dangle! - props.onSuccess(subscription); - }; - - return ( - - {!showLoginPage && ( - setShowLoginPage(true)} - onSuccess={handleSuccess} - /> - )} - {showLoginPage && setShowLoginPage(false)} onSuccess={handleSuccess} />} - - ); -}; - -const SubscribePage = (props) => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [error, setError] = useState(""); - const [reserveTopicVisible, setReserveTopicVisible] = useState(false); - const [anotherServerVisible, setAnotherServerVisible] = useState(false); - const [everyone, setEveryone] = useState(Permission.DENY_ALL); - const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url; - const { topic } = props; - const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic)); - const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter( - (s) => s !== config.base_url - ); - const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account); - const reserveTopicEnabled = - session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0)); - - const handleSubscribe = async () => { - const user = await userManager.get(baseUrl); // May be undefined - const username = user ? user.username : t("subscribe_dialog_error_user_anonymous"); - - // Check read access to topic - const success = await api.topicAuth(baseUrl, topic, user); - if (!success) { - console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); - if (user) { - setError( - t("subscribe_dialog_error_user_not_authorized", { - username, - }) - ); - return; - } - props.onNeedsLogin(); - return; - } - - // Reserve topic (if requested) - if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) { - console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`); - try { - await accountApi.upsertReservation(topic, everyone); - } catch (e) { - console.log(`[SubscribeDialog] Error reserving topic`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else if (e instanceof TopicReservedError) { - setError(t("subscribe_dialog_error_topic_already_reserved")); - return; - } - } - } - - console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); - props.onSuccess(); - }; - - const handleUseAnotherChanged = (e) => { - props.setBaseUrl(""); - setAnotherServerVisible(e.target.checked); - }; - - const subscribeButtonEnabled = (() => { - if (anotherServerVisible) { - const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); - return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; - } - const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic)); - return validTopic(topic) && !isExistingTopicUrl; - })(); - - const updateBaseUrl = (ev, newVal) => { - if (validUrl(newVal)) { - props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?:// - } else { - props.setBaseUrl(newVal); - } - }; - - return ( - <> - {t("subscribe_dialog_subscribe_title")} - - {t("subscribe_dialog_subscribe_description")} -
- props.setTopic(ev.target.value)} - type="text" - fullWidth - variant="standard" - inputProps={{ - maxLength: 64, - "aria-label": t("subscribe_dialog_subscribe_topic_placeholder"), - }} - /> - -
- {showReserveTopicCheckbox && ( - - setReserveTopicVisible(ev.target.checked)} - inputProps={{ - "aria-label": t("reserve_dialog_checkbox_label"), - }} - /> - } - label={ - <> - {t("reserve_dialog_checkbox_label")} - - - } - /> - {reserveTopicVisible && } - - )} - {!reserveTopicVisible && ( - - - } - label={t("subscribe_dialog_subscribe_use_another_label")} - /> - {anotherServerVisible && ( - ( - - )} - /> - )} - - )} -
- - - - - - ); -}; - -const LoginPage = (props) => { - const { t } = useTranslation(); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const baseUrl = props.baseUrl ? props.baseUrl : config.base_url; - const { topic } = props; - - const handleLogin = async () => { - const user = { baseUrl, username, password }; - const success = await api.topicAuth(baseUrl, topic, user); - if (!success) { - console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); - setError(t("subscribe_dialog_error_user_not_authorized", { username })); - return; - } - console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); - await userManager.save(user); - props.onSuccess(); - }; - - return ( - <> - {t("subscribe_dialog_login_title")} - - {t("subscribe_dialog_login_description")} - setUsername(ev.target.value)} - type="text" - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("subscribe_dialog_login_username_label"), - }} - /> - setPassword(ev.target.value)} - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("subscribe_dialog_login_password_label"), - }} - /> - - - - - - - ); -}; - -export default SubscribeDialog; diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.js new file mode 100644 index 0000000..b33313c --- /dev/null +++ b/web/src/components/SubscriptionPopup.js @@ -0,0 +1,292 @@ +import * as React from 'react'; +import {useContext, useState} from 'react'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import {Chip, InputAdornment, Portal, Snackbar, useMediaQuery} from "@mui/material"; +import theme from "./theme"; +import subscriptionManager from "../app/SubscriptionManager"; +import DialogFooter from "./DialogFooter"; +import {useTranslation} from "react-i18next"; +import accountApi from "../app/AccountApi"; +import session from "../app/Session"; +import routes from "./routes"; +import MenuItem from "@mui/material/MenuItem"; +import PopupMenu from "./PopupMenu"; +import {formatShortDateTime, shuffle} from "../app/utils"; +import api from "../app/Api"; +import {useNavigate} from "react-router-dom"; +import IconButton from "@mui/material/IconButton"; +import {Clear} from "@mui/icons-material"; +import {AccountContext} from "./App"; +import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs"; +import {UnauthorizedError} from "../app/errors"; + +export const SubscriptionPopup = (props) => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const navigate = useNavigate(); + const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false); + const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false); + const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false); + const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false); + const [showPublishError, setShowPublishError] = useState(false); + const subscription = props.subscription; + const placement = props.placement ?? "left"; + const reservations = account?.reservations || []; + + const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0; + const showReservationAddDisabled = !showReservationAdd && config.enable_reservations && !subscription?.reservation && (config.enable_payments || account?.stats.reservations_remaining === 0); + const showReservationEdit = config.enable_reservations && !!subscription?.reservation; + const showReservationDelete = config.enable_reservations && !!subscription?.reservation; + + const handleChangeDisplayName = async () => { + setDisplayNameDialogOpen(true); + } + + const handleReserveAdd = async () => { + setReserveAddDialogOpen(true); + } + + const handleReserveEdit = async () => { + setReserveEditDialogOpen(true); + } + + const handleReserveDelete = async () => { + setReserveDeleteDialogOpen(true); + } + + const handleSendTestMessage = async () => { + const baseUrl = props.subscription.baseUrl; + const topic = props.subscription.topic; + const tags = shuffle([ + "grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern", + "de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"]) + .slice(0, Math.round(Math.random() * 4)); + const priority = shuffle([1, 2, 3, 4, 5])[0]; + const title = shuffle([ + "", + "", + "", // Higher chance of no title + "Oh my, another test message?", + "Titles are optional, did you know that?", + "ntfy is open source, and will always be free. Cool, right?", + "I don't really like apples", + "My favorite TV show is The Wire. You should watch it!", + "You can attach files and URLs to messages too", + "You can delay messages up to 3 days" + ])[0]; + const nowSeconds = Math.round(Date.now()/1000); + const message = shuffle([ + `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`, + `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, + `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, + `Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, + `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, + `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, + `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?` + ])[0]; + try { + await api.publish(baseUrl, topic, message, { + title: title, + priority: priority, + tags: tags + }); + } catch (e) { + console.log(`[SubscriptionPopup] Error publishing message`, e); + setShowPublishError(true); + } + } + + const handleClearAll = async () => { + console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`); + await subscriptionManager.deleteNotifications(props.subscription.id); + }; + + const handleUnsubscribe = async () => { + console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription); + await subscriptionManager.remove(props.subscription.id); + if (session.exists() && !subscription.internal) { + try { + await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic); + } catch (e) { + console.log(`[SubscriptionPopup] Error unsubscribing`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } + } + const newSelected = await subscriptionManager.first(); // May be undefined + if (newSelected && !newSelected.internal) { + navigate(routes.forSubscription(newSelected)); + } else { + navigate(routes.app); + } + }; + + return ( + <> + + {t("action_bar_change_display_name")} + {showReservationAdd && {t("action_bar_reservation_add")}} + {showReservationAddDisabled && + + {t("action_bar_reservation_add")} + + + } + {showReservationEdit && {t("action_bar_reservation_edit")}} + {showReservationDelete && {t("action_bar_reservation_delete")}} + {t("action_bar_send_test_notification")} + {t("action_bar_clear_notifications")} + {t("action_bar_unsubscribe")} + + + setShowPublishError(false)} + message={t("message_bar_error_publishing")} + /> + setDisplayNameDialogOpen(false)} + /> + {showReservationAdd && + setReserveAddDialogOpen(false)} + /> + } + {showReservationEdit && + setReserveEditDialogOpen(false)} + /> + } + {showReservationDelete && + setReserveDeleteDialogOpen(false)} + /> + } + + + ); +}; + +const DisplayNameDialog = (props) => { + const { t } = useTranslation(); + const subscription = props.subscription; + const [error, setError] = useState(""); + const [displayName, setDisplayName] = useState(subscription.displayName ?? ""); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const handleSave = async () => { + await subscriptionManager.setDisplayName(subscription.id, displayName); + if (session.exists() && !subscription.internal) { + try { + console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); + await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName }); + } catch (e) { + console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; + } + } + } + props.onClose(); + } + + return ( + + {t("display_name_dialog_title")} + + + {t("display_name_dialog_description")} + + setDisplayName(ev.target.value)} + type="text" + fullWidth + variant="standard" + inputProps={{ + maxLength: 64, + "aria-label": t("display_name_dialog_placeholder") + }} + InputProps={{ + endAdornment: ( + + setDisplayName("")} edge="end"> + + + + ) + }} + /> + + + + + + + ); +}; + +export const ReserveLimitChip = () => { + const { account } = useContext(AccountContext); + if (account?.stats.reservations_remaining > 0) { + return <>; + } else if (config.enable_payments) { + return (account?.limits.reservations > 0) ? : ; + } else if (account) { + return ; + } + return <>; +}; + +const LimitReachedChip = () => { + const { t } = useTranslation(); + return ( + + ); +}; + +const ProChip = () => { + const { t } = useTranslation(); + return ( + + ); +}; + + diff --git a/web/src/components/SubscriptionPopup.jsx b/web/src/components/SubscriptionPopup.jsx deleted file mode 100644 index ee83a11..0000000 --- a/web/src/components/SubscriptionPopup.jsx +++ /dev/null @@ -1,314 +0,0 @@ -import * as React from "react"; -import { useContext, useState } from "react"; -import { - Button, - TextField, - Dialog, - DialogContent, - DialogContentText, - DialogTitle, - Chip, - InputAdornment, - Portal, - Snackbar, - useMediaQuery, - MenuItem, - IconButton, -} from "@mui/material"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; -import { Clear } from "@mui/icons-material"; -import theme from "./theme"; -import subscriptionManager from "../app/SubscriptionManager"; -import DialogFooter from "./DialogFooter"; -import accountApi, { Role } from "../app/AccountApi"; -import session from "../app/Session"; -import routes from "./routes"; -import PopupMenu from "./PopupMenu"; -import { formatShortDateTime, shuffle } from "../app/utils"; -import api from "../app/Api"; -import { AccountContext } from "./App"; -import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; -import { UnauthorizedError } from "../app/errors"; - -export const SubscriptionPopup = (props) => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const navigate = useNavigate(); - const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false); - const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false); - const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false); - const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false); - const [showPublishError, setShowPublishError] = useState(false); - const { subscription } = props; - const placement = props.placement ?? "left"; - const reservations = account?.reservations || []; - - const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0; - const showReservationAddDisabled = - !showReservationAdd && - config.enable_reservations && - !subscription?.reservation && - (config.enable_payments || account?.stats.reservations_remaining === 0); - const showReservationEdit = config.enable_reservations && !!subscription?.reservation; - const showReservationDelete = config.enable_reservations && !!subscription?.reservation; - - const handleChangeDisplayName = async () => { - setDisplayNameDialogOpen(true); - }; - - const handleReserveAdd = async () => { - setReserveAddDialogOpen(true); - }; - - const handleReserveEdit = async () => { - setReserveEditDialogOpen(true); - }; - - const handleReserveDelete = async () => { - setReserveDeleteDialogOpen(true); - }; - - const handleSendTestMessage = async () => { - const { baseUrl } = props.subscription; - const { topic } = props.subscription; - const tags = shuffle([ - "grinning", - "octopus", - "upside_down_face", - "palm_tree", - "maple_leaf", - "apple", - "skull", - "warning", - "jack_o_lantern", - "de-server-1", - "backups", - "cron-script", - "script-error", - "phils-automation", - "mouse", - "go-rocks", - "hi-ben", - ]).slice(0, Math.round(Math.random() * 4)); - const priority = shuffle([1, 2, 3, 4, 5])[0]; - const title = shuffle([ - "", - "", - "", // Higher chance of no title - "Oh my, another test message?", - "Titles are optional, did you know that?", - "ntfy is open source, and will always be free. Cool, right?", - "I don't really like apples", - "My favorite TV show is The Wire. You should watch it!", - "You can attach files and URLs to messages too", - "You can delay messages up to 3 days", - ])[0]; - const nowSeconds = Math.round(Date.now() / 1000); - const message = shuffle([ - `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`, - `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, - `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, - `Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, - `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, - `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, - `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`, - ])[0]; - try { - await api.publish(baseUrl, topic, message, { - title, - priority, - tags, - }); - } catch (e) { - console.log(`[SubscriptionPopup] Error publishing message`, e); - setShowPublishError(true); - } - }; - - const handleClearAll = async () => { - console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`); - await subscriptionManager.deleteNotifications(props.subscription.id); - }; - - const handleUnsubscribe = async () => { - console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription); - await subscriptionManager.remove(props.subscription.id); - if (session.exists() && !subscription.internal) { - try { - await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic); - } catch (e) { - console.log(`[SubscriptionPopup] Error unsubscribing`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } - } - const newSelected = await subscriptionManager.first(); // May be undefined - if (newSelected && !newSelected.internal) { - navigate(routes.forSubscription(newSelected)); - } else { - navigate(routes.app); - } - }; - - return ( - <> - - {t("action_bar_change_display_name")} - {showReservationAdd && {t("action_bar_reservation_add")}} - {showReservationAddDisabled && ( - - {t("action_bar_reservation_add")} - - - )} - {showReservationEdit && {t("action_bar_reservation_edit")}} - {showReservationDelete && {t("action_bar_reservation_delete")}} - {t("action_bar_send_test_notification")} - {t("action_bar_clear_notifications")} - {t("action_bar_unsubscribe")} - - - setShowPublishError(false)} - message={t("message_bar_error_publishing")} - /> - setDisplayNameDialogOpen(false)} /> - {showReservationAdd && ( - setReserveAddDialogOpen(false)} - /> - )} - {showReservationEdit && ( - setReserveEditDialogOpen(false)} - /> - )} - {showReservationDelete && ( - setReserveDeleteDialogOpen(false)} - /> - )} - - - ); -}; - -const DisplayNameDialog = (props) => { - const { t } = useTranslation(); - const { subscription } = props; - const [error, setError] = useState(""); - const [displayName, setDisplayName] = useState(subscription.displayName ?? ""); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - - const handleSave = async () => { - await subscriptionManager.setDisplayName(subscription.id, displayName); - if (session.exists() && !subscription.internal) { - try { - console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); - await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName }); - } catch (e) { - console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - return; - } - } - } - props.onClose(); - }; - - return ( - - {t("display_name_dialog_title")} - - {t("display_name_dialog_description")} - setDisplayName(ev.target.value)} - type="text" - fullWidth - variant="standard" - inputProps={{ - maxLength: 64, - "aria-label": t("display_name_dialog_placeholder"), - }} - InputProps={{ - endAdornment: ( - - setDisplayName("")} edge="end"> - - - - ), - }} - /> - - - - - - - ); -}; - -export const ReserveLimitChip = () => { - const { account } = useContext(AccountContext); - if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) { - return <>; - } - if (config.enable_payments) { - return account?.limits.reservations > 0 ? : ; - } - if (account) { - return ; - } - return <>; -}; - -const LimitReachedChip = () => { - const { t } = useTranslation(); - return ( - - ); -}; - -export const ProChip = () => ( - -); diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js new file mode 100644 index 0000000..247131c --- /dev/null +++ b/web/src/components/UpgradeDialog.js @@ -0,0 +1,266 @@ +import * as React from 'react'; +import {useContext, useEffect, useState} from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import {Alert, CardActionArea, CardContent, ListItem, useMediaQuery} from "@mui/material"; +import theme from "./theme"; +import DialogFooter from "./DialogFooter"; +import Button from "@mui/material/Button"; +import accountApi from "../app/AccountApi"; +import session from "../app/Session"; +import routes from "./routes"; +import Card from "@mui/material/Card"; +import Typography from "@mui/material/Typography"; +import {AccountContext} from "./App"; +import {formatBytes, formatNumber, formatShortDate} from "../app/utils"; +import {Trans, useTranslation} from "react-i18next"; +import List from "@mui/material/List"; +import {Check} from "@mui/icons-material"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Box from "@mui/material/Box"; +import {NavLink} from "react-router-dom"; +import {UnauthorizedError} from "../app/errors"; + +const UpgradeDialog = (props) => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); // May be undefined! + const [error, setError] = useState(""); + const [tiers, setTiers] = useState(null); + const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined + const [loading, setLoading] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + useEffect(() => { + const fetchTiers = async () => { + setTiers(await accountApi.billingTiers()); + } + fetchTiers(); // Dangle + }, []); + + if (!tiers) { + return <>; + } + + const tiersMap = Object.assign(...tiers.map(tier => ({[tier.code]: tier}))); + const newTier = tiersMap[newTierCode]; // May be undefined + const currentTier = account?.tier; // May be undefined + const currentTierCode = currentTier?.code; // May be undefined + + // Figure out buttons, labels and the submit action + let submitAction, submitButtonLabel, banner; + if (!account) { + submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); + submitAction = Action.REDIRECT_SIGNUP; + banner = null; + } else if (currentTierCode === newTierCode) { + submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); + submitAction = null; + banner = (currentTierCode) ? Banner.PRORATION_INFO : null; + } else if (!currentTierCode) { + submitButtonLabel = t("account_upgrade_dialog_button_pay_now"); + submitAction = Action.CREATE_SUBSCRIPTION; + banner = null; + } else if (!newTierCode) { + submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription"); + submitAction = Action.CANCEL_SUBSCRIPTION; + banner = Banner.CANCEL_WARNING; + } else { + submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); + submitAction = Action.UPDATE_SUBSCRIPTION; + banner = Banner.PRORATION_INFO; + } + + // Exceptional conditions + if (loading) { + submitAction = null; + } else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) { + submitAction = null; + banner = Banner.RESERVATIONS_WARNING; + } + + const handleSubmit = async () => { + if (submitAction === Action.REDIRECT_SIGNUP) { + window.location.href = routes.signup; + return; + } + try { + setLoading(true); + if (submitAction === Action.CREATE_SUBSCRIPTION) { + const response = await accountApi.createBillingSubscription(newTierCode); + window.location.href = response.redirect_url; + } else if (submitAction === Action.UPDATE_SUBSCRIPTION) { + await accountApi.updateBillingSubscription(newTierCode); + } else if (submitAction === Action.CANCEL_SUBSCRIPTION) { + await accountApi.deleteBillingSubscription(); + } + props.onCancel(); + } catch (e) { + console.log(`[UpgradeDialog] Error changing billing subscription`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setLoading(false); + } + } + + return ( + + {t("account_upgrade_dialog_title")} + +
+ {tiers.map(tier => + setNewTierCode(tier.code)} // tier.code may be undefined! + /> + )} +
+ {banner === Banner.CANCEL_WARNING && + + + + } + {banner === Banner.PRORATION_INFO && + + + + } + {banner === Banner.RESERVATIONS_WARNING && + + , + }} + /> + + } +
+ + + + +
+ ); +}; + +const TierCard = (props) => { + const { t } = useTranslation(); + const tier = props.tier; + let cardStyle, labelStyle, labelText; + if (props.selected) { + cardStyle = { background: "#eee", border: "2px solid #338574" }; + labelStyle = { background: "#338574", color: "white" }; + labelText = t("account_upgrade_dialog_tier_selected_label"); + } else if (props.current) { + cardStyle = { border: "2px solid #eee" }; + labelStyle = { background: "#eee", color: "black" }; + labelText = t("account_upgrade_dialog_tier_current_label"); + } else { + cardStyle = { border: "2px solid transparent" }; + } + + return ( + + + + + {labelStyle && +
{labelText}
+ } + + {tier.name || t("account_basics_tier_free")} + + + {tier.limits.reservations > 0 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations })}} + {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages) })} + {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails) })} + {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} + {t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })} + + {tier.price && + + {tier.price} / month + + } +
+
+
+
+ + ); +} + +const FeatureItem = (props) => { + return ( + + + + + + {props.children} + + } + /> + + + ); +}; + +const Action = { + REDIRECT_SIGNUP: 1, + CREATE_SUBSCRIPTION: 2, + UPDATE_SUBSCRIPTION: 3, + CANCEL_SUBSCRIPTION: 4 +}; + +const Banner = { + CANCEL_WARNING: 1, + PRORATION_INFO: 2, + RESERVATIONS_WARNING: 3 +}; + +export default UpgradeDialog; diff --git a/web/src/components/UpgradeDialog.jsx b/web/src/components/UpgradeDialog.jsx deleted file mode 100644 index a554f1f..0000000 --- a/web/src/components/UpgradeDialog.jsx +++ /dev/null @@ -1,435 +0,0 @@ -import * as React from "react"; -import { useContext, useEffect, useState } from "react"; -import { - Dialog, - DialogContent, - DialogTitle, - Alert, - CardActionArea, - CardContent, - Chip, - Link, - ListItem, - Switch, - useMediaQuery, - Button, - Card, - Typography, - List, - ListItemIcon, - ListItemText, - Box, - DialogContentText, - DialogActions, -} from "@mui/material"; -import { Trans, useTranslation } from "react-i18next"; -import { Check, Close } from "@mui/icons-material"; -import { NavLink } from "react-router-dom"; -import { UnauthorizedError } from "../app/errors"; -import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils"; -import { AccountContext } from "./App"; -import routes from "./routes"; -import session from "../app/Session"; -import accountApi, { SubscriptionInterval } from "../app/AccountApi"; -import theme from "./theme"; - -const Feature = (props) => {props.children}; - -const NoFeature = (props) => {props.children}; - -const FeatureItem = (props) => ( - - - {props.feature && } - {!props.feature && } - - {props.children}} /> - -); - -const Action = { - REDIRECT_SIGNUP: 1, - CREATE_SUBSCRIPTION: 2, - UPDATE_SUBSCRIPTION: 3, - CANCEL_SUBSCRIPTION: 4, -}; - -const Banner = { - CANCEL_WARNING: 1, - PRORATION_INFO: 2, - RESERVATIONS_WARNING: 3, -}; - -const UpgradeDialog = (props) => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); // May be undefined! - const [error, setError] = useState(""); - const [tiers, setTiers] = useState(null); - const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR); - const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined - const [loading, setLoading] = useState(false); - const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - - useEffect(() => { - const fetchTiers = async () => { - setTiers(await accountApi.billingTiers()); - }; - fetchTiers(); // Dangle - }, []); - - if (!tiers) { - return <>; - } - - const tiersMap = Object.assign(...tiers.map((tier) => ({ [tier.code]: tier }))); - const newTier = tiersMap[newTierCode]; // May be undefined - const currentTier = account?.tier; // May be undefined - const currentInterval = account?.billing?.interval; // May be undefined - const currentTierCode = currentTier?.code; // May be undefined - - // Figure out buttons, labels and the submit action - let submitAction; - let submitButtonLabel; - let banner; - if (!account) { - submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); - submitAction = Action.REDIRECT_SIGNUP; - banner = null; - } else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) { - submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); - submitAction = null; - banner = currentTierCode ? Banner.PRORATION_INFO : null; - } else if (!currentTierCode) { - submitButtonLabel = t("account_upgrade_dialog_button_pay_now"); - submitAction = Action.CREATE_SUBSCRIPTION; - banner = null; - } else if (!newTierCode) { - submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription"); - submitAction = Action.CANCEL_SUBSCRIPTION; - banner = Banner.CANCEL_WARNING; - } else { - submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); - submitAction = Action.UPDATE_SUBSCRIPTION; - banner = Banner.PRORATION_INFO; - } - - // Exceptional conditions - if (loading) { - submitAction = null; - } else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) { - submitAction = null; - banner = Banner.RESERVATIONS_WARNING; - } - - const handleSubmit = async () => { - if (submitAction === Action.REDIRECT_SIGNUP) { - window.location.href = routes.signup; - return; - } - try { - setLoading(true); - if (submitAction === Action.CREATE_SUBSCRIPTION) { - const response = await accountApi.createBillingSubscription(newTierCode, interval); - window.location.href = response.redirect_url; - } else if (submitAction === Action.UPDATE_SUBSCRIPTION) { - await accountApi.updateBillingSubscription(newTierCode, interval); - } else if (submitAction === Action.CANCEL_SUBSCRIPTION) { - await accountApi.deleteBillingSubscription(); - } - props.onCancel(); - } catch (e) { - console.log(`[UpgradeDialog] Error changing billing subscription`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } finally { - setLoading(false); - } - }; - - // Figure out discount - let discount = 0; - let upto = false; - if (newTier?.prices) { - discount = Math.round(((newTier.prices.month * 12) / newTier.prices.year - 1) * 100); - } else { - let n = 0; - for (const tier of tiers) { - if (tier.prices) { - const tierDiscount = Math.round(((tier.prices.month * 12) / tier.prices.year - 1) * 100); - if (tierDiscount > discount) { - discount = tierDiscount; - n += 1; - } - } - } - upto = n > 1; - } - - return ( - - -
-
{t("account_upgrade_dialog_title")}
-
- - {t("account_upgrade_dialog_interval_monthly")} - - setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)} - /> - - {t("account_upgrade_dialog_interval_yearly")} - - {discount > 0 && ( - - )} -
-
-
- -
- {tiers.map((tier) => ( - setNewTierCode(tier.code)} // tier.code may be undefined! - /> - ))} -
- {banner === Banner.CANCEL_WARNING && ( - - - - )} - {banner === Banner.PRORATION_INFO && ( - - - - )} - {banner === Banner.RESERVATIONS_WARNING && ( - - , - }} - /> - - )} -
- - - {config.billing_contact.indexOf("@") !== -1 && ( - <> - , - }} - />{" "} - - )} - {config.billing_contact.match(`^http?s://`) && ( - <> - , - }} - />{" "} - - )} - {error} - - - - - - -
- ); -}; - -const TierCard = (props) => { - const { t } = useTranslation(); - const { tier } = props; - - let cardStyle; - let labelStyle; - let labelText; - if (props.selected) { - cardStyle = { background: "#eee", border: "3px solid #338574" }; - labelStyle = { background: "#338574", color: "white" }; - labelText = t("account_upgrade_dialog_tier_selected_label"); - } else if (props.current) { - cardStyle = { border: "3px solid #eee" }; - labelStyle = { background: "#eee", color: "black" }; - labelText = t("account_upgrade_dialog_tier_current_label"); - } else { - cardStyle = { border: "3px solid transparent" }; - } - - let monthlyPrice; - if (!tier.prices) { - monthlyPrice = 0; - } else if (props.interval === SubscriptionInterval.YEAR) { - monthlyPrice = tier.prices.year / 12; - } else if (props.interval === SubscriptionInterval.MONTH) { - monthlyPrice = tier.prices.month; - } - - return ( - - - - - {labelStyle && ( -
- {labelText} -
- )} - - {tier.name || t("account_basics_tier_free")} - -
- - {formatPrice(monthlyPrice)} - - {monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}} -
- - {tier.limits.reservations > 0 && ( - - {t("account_upgrade_dialog_tier_features_reservations", { - reservations: tier.limits.reservations, - count: tier.limits.reservations, - })} - - )} - - {t("account_upgrade_dialog_tier_features_messages", { - messages: formatNumber(tier.limits.messages), - count: tier.limits.messages, - })} - - - {t("account_upgrade_dialog_tier_features_emails", { - emails: formatNumber(tier.limits.emails), - count: tier.limits.emails, - })} - - {tier.limits.calls > 0 && ( - - {t("account_upgrade_dialog_tier_features_calls", { - calls: formatNumber(tier.limits.calls), - count: tier.limits.calls, - })} - - )} - - {t("account_upgrade_dialog_tier_features_attachment_file_size", { - filesize: formatBytes(tier.limits.attachment_file_size, 0), - })} - - {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} - {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} - - {tier.prices && props.interval === SubscriptionInterval.MONTH && ( - - {t("account_upgrade_dialog_tier_price_billed_monthly", { - price: formatPrice(tier.prices.month * 12), - })} - - )} - {tier.prices && props.interval === SubscriptionInterval.YEAR && ( - - {t("account_upgrade_dialog_tier_price_billed_yearly", { - price: formatPrice(tier.prices.year), - save: formatPrice(tier.prices.month * 12 - tier.prices.year), - })} - - )} -
-
-
-
- ); -}; - -export default UpgradeDialog; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 6b68188..b1ce8ff 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -1,7 +1,7 @@ -import { useNavigate, useParams } from "react-router-dom"; -import { useEffect, useState } from "react"; +import {useNavigate, useParams} from "react-router-dom"; +import {useEffect, useState} from "react"; import subscriptionManager from "../app/SubscriptionManager"; -import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils"; +import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils"; import notifier from "../app/Notifier"; import routes from "./routes"; import connectionManager from "../app/ConnectionManager"; @@ -9,7 +9,7 @@ import poller from "../app/Poller"; import pruner from "../app/Pruner"; import session from "../app/Session"; import accountApi from "../app/AccountApi"; -import { UnauthorizedError } from "../app/errors"; +import {UnauthorizedError} from "../app/errors"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -17,75 +17,65 @@ import { UnauthorizedError } from "../app/errors"; * to the connection being re-established). */ export const useConnectionListeners = (account, subscriptions, users) => { - const navigate = useNavigate(); + const navigate = useNavigate(); - // Register listeners for incoming messages, and connection state changes - useEffect( - () => { - const handleInternalMessage = async (message) => { - console.log(`[ConnectionListener] Received message on sync topic`, message.message); - try { - const data = JSON.parse(message.message); - if (data.event === "sync") { - console.log(`[ConnectionListener] Triggering account sync`); - await accountApi.sync(); - } else { - console.log(`[ConnectionListener] Unknown message type. Doing nothing.`); - } - } catch (e) { - console.log(`[ConnectionListener] Error parsing sync topic message`, e); + // Register listeners for incoming messages, and connection state changes + useEffect(() => { + const handleMessage = async (subscriptionId, message) => { + const subscription = await subscriptionManager.get(subscriptionId); + if (subscription.internal) { + await handleInternalMessage(message); + } else { + await handleNotification(subscriptionId, message); + } + }; + + const handleInternalMessage = async (message) => { + console.log(`[ConnectionListener] Received message on sync topic`, message.message); + try { + const data = JSON.parse(message.message); + if (data.event === "sync") { + console.log(`[ConnectionListener] Triggering account sync`); + await accountApi.sync(); + } else { + console.log(`[ConnectionListener] Unknown message type. Doing nothing.`); + } + } catch (e) { + console.log(`[ConnectionListener] Error parsing sync topic message`, e); + } + }; + + const handleNotification = async (subscriptionId, notification) => { + const added = await subscriptionManager.addNotification(subscriptionId, notification); + if (added) { + const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); + await notifier.notify(subscriptionId, notification, defaultClickAction) + } + }; + connectionManager.registerStateListener(subscriptionManager.updateState); + connectionManager.registerMessageListener(handleMessage); + return () => { + connectionManager.resetStateListener(); + connectionManager.resetMessageListener(); + } + }, + // We have to disable dep checking for "navigate". This is fine, it never changes. + // eslint-disable-next-line + [] + ); + + // Sync topic listener: For accounts with sync_topic, subscribe to an internal topic + useEffect(() => { + if (!account || !account.sync_topic) { + return; } - }; + subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle! + }, [account]); - const handleNotification = async (subscriptionId, notification) => { - const added = await subscriptionManager.addNotification(subscriptionId, notification); - if (added) { - const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); - await notifier.notify(subscriptionId, notification, defaultClickAction); - } - }; - - const handleMessage = async (subscriptionId, message) => { - const subscription = await subscriptionManager.get(subscriptionId); - - // Race condition: sometimes the subscription is already unsubscribed from account - // sync before the message is handled - if (!subscription) { - return; - } - - if (subscription.internal) { - await handleInternalMessage(message); - } else { - await handleNotification(subscriptionId, message); - } - }; - - connectionManager.registerStateListener(subscriptionManager.updateState); - connectionManager.registerMessageListener(handleMessage); - - return () => { - connectionManager.resetStateListener(); - connectionManager.resetMessageListener(); - }; - }, - // We have to disable dep checking for "navigate". This is fine, it never changes. - - [] - ); - - // Sync topic listener: For accounts with sync_topic, subscribe to an internal topic - useEffect(() => { - if (!account || !account.sync_topic) { - return; - } - subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle! - }, [account]); - - // When subscriptions or users change, refresh the connections - useEffect(() => { - connectionManager.refresh(subscriptions, users); // Dangle - }, [subscriptions, users]); + // When subscriptions or users change, refresh the connections + useEffect(() => { + connectionManager.refresh(subscriptions, users); // Dangle + }, [subscriptions, users]); }; /** @@ -93,35 +83,35 @@ export const useConnectionListeners = (account, subscriptions, users) => { * This will only be run once after the initial page load. */ export const useAutoSubscribe = (subscriptions, selected) => { - const [hasRun, setHasRun] = useState(false); - const params = useParams(); + const [hasRun, setHasRun] = useState(false); + const params = useParams(); - useEffect(() => { - const loaded = subscriptions !== null && subscriptions !== undefined; - if (!loaded || hasRun) { - return; - } - setHasRun(true); - const eligible = params.topic && !selected && !disallowedTopic(params.topic); - if (eligible) { - const baseUrl = params.baseUrl ? expandSecureUrl(params.baseUrl) : config.base_url; - console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); - (async () => { - const subscription = await subscriptionManager.add(baseUrl, params.topic); - if (session.exists()) { - try { - await accountApi.addSubscription(baseUrl, params.topic); - } catch (e) { - console.log(`[Hooks] Auto-subscribing failed`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } + useEffect(() => { + const loaded = subscriptions !== null && subscriptions !== undefined; + if (!loaded || hasRun) { + return; } - poller.pollInBackground(subscription); // Dangle! - })(); - } - }, [params, subscriptions, selected, hasRun]); + setHasRun(true); + const eligible = params.topic && !selected && !disallowedTopic(params.topic); + if (eligible) { + const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.base_url; + console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); + (async () => { + const subscription = await subscriptionManager.add(baseUrl, params.topic); + if (session.exists()) { + try { + await accountApi.addSubscription(baseUrl, params.topic); + } catch (e) { + console.log(`[Hooks] Auto-subscribing failed`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } + } + poller.pollInBackground(subscription); // Dangle! + })(); + } + }, [params, subscriptions, selected, hasRun]); }; /** @@ -130,19 +120,19 @@ export const useAutoSubscribe = (subscriptions, selected) => { * up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186. */ export const useBackgroundProcesses = () => { - useEffect(() => { - poller.startWorker(); - pruner.startWorker(); - accountApi.startWorker(); - }, []); -}; + useEffect(() => { + poller.startWorker(); + pruner.startWorker(); + accountApi.startWorker(); + }, []); +} export const useAccountListener = (setAccount) => { - useEffect(() => { - accountApi.registerListener(setAccount); - accountApi.sync(); // Dangle - return () => { - accountApi.resetListener(); - }; - }, []); -}; + useEffect(() => { + accountApi.registerListener(setAccount); + accountApi.sync(); // Dangle + return () => { + accountApi.resetListener(); + } + }, []); +} diff --git a/web/src/components/i18n.js b/web/src/components/i18n.js new file mode 100644 index 0000000..42eb572 --- /dev/null +++ b/web/src/components/i18n.js @@ -0,0 +1,29 @@ +import i18n from 'i18next'; +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { initReactI18next } from 'react-i18next'; + +// Translations using i18next +// - Options: https://www.i18next.com/overview/configuration-options +// - Browser Language Detector: https://github.com/i18next/i18next-browser-languageDetector +// - HTTP Backend (load files via fetch): https://github.com/i18next/i18next-http-backend +// +// See example project here: +// https://github.com/i18next/react-i18next/tree/master/example/react + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + debug: true, + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + backend: { + loadPath: '/static/langs/{{lng}}.json', + } + }); + +export default i18n; diff --git a/web/src/components/i18n.jsx b/web/src/components/i18n.jsx deleted file mode 100644 index 2bc315c..0000000 --- a/web/src/components/i18n.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import i18n from "i18next"; -import Backend from "i18next-http-backend"; -import LanguageDetector from "i18next-browser-languagedetector"; -import { initReactI18next } from "react-i18next"; - -// Translations using i18next -// - Options: https://www.i18next.com/overview/configuration-options -// - Browser Language Detector: https://github.com/i18next/i18next-browser-languageDetector -// - HTTP Backend (load files via fetch): https://github.com/i18next/i18next-http-backend -// -// See example project here: -// https://github.com/i18next/react-i18next/tree/master/example/react - -i18n - .use(Backend) - .use(LanguageDetector) - .use(initReactI18next) - .init({ - fallbackLng: "en", - debug: true, - interpolation: { - escapeValue: false, // not needed for react as it escapes by default - }, - backend: { - loadPath: "/static/langs/{{lng}}.json", - }, - }); - -export default i18n; diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 17e0eac..d1db160 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -1,20 +1,20 @@ import config from "../app/config"; -import { shortUrl } from "../app/utils"; +import {shortUrl} from "../app/utils"; const routes = { - login: "/login", - signup: "/signup", - app: config.app_root, - account: "/account", - settings: "/settings", - subscription: "/:topic", - subscriptionExternal: "/:baseUrl/:topic", - forSubscription: (subscription) => { - if (subscription.baseUrl !== config.base_url) { - return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; + login: "/login", + signup: "/signup", + app: config.app_root, + account: "/account", + settings: "/settings", + subscription: "/:topic", + subscriptionExternal: "/:baseUrl/:topic", + forSubscription: (subscription) => { + if (subscription.baseUrl !== config.base_url) { + return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; + } + return `/${subscription.topic}`; } - return `/${subscription.topic}`; - }, }; export default routes; diff --git a/web/src/components/styles.js b/web/src/components/styles.js index edcfb46..d612794 100644 --- a/web/src/components/styles.js +++ b/web/src/components/styles.js @@ -1,5 +1,7 @@ -import { Typography, Container, Backdrop, styled } from "@mui/material"; +import Typography from "@mui/material/Typography"; import theme from "./theme"; +import Container from "@mui/material/Container"; +import {Backdrop, styled} from "@mui/material"; export const Paragraph = styled(Typography)({ paddingTop: 8, @@ -7,14 +9,14 @@ export const Paragraph = styled(Typography)({ }); export const VerticallyCenteredContainer = styled(Container)({ - display: "flex", + display: 'flex', flexGrow: 1, - flexDirection: "column", - justifyContent: "center", - alignContent: "center", - color: theme.palette.text.primary, + flexDirection: 'column', + justifyContent: 'center', + alignContent: 'center', + color: theme.palette.text.primary }); export const LightboxBackdrop = styled(Backdrop)({ - backgroundColor: "rgba(0, 0, 0, 0.8)", // was: 0.5 + backgroundColor: 'rgba(0, 0, 0, 0.8)' // was: 0.5 }); diff --git a/web/src/components/theme.js b/web/src/components/theme.js index ca77cdc..3fdafae 100644 --- a/web/src/components/theme.js +++ b/web/src/components/theme.js @@ -1,13 +1,13 @@ -import { red } from "@mui/material/colors"; -import { createTheme } from "@mui/material/styles"; +import { red } from '@mui/material/colors'; +import { createTheme } from '@mui/material/styles'; const theme = createTheme({ palette: { primary: { - main: "#338574", + main: '#338574', }, secondary: { - main: "#6cead0", + main: '#6cead0', }, error: { main: red.A400, @@ -17,19 +17,19 @@ const theme = createTheme({ MuiListItemIcon: { styleOverrides: { root: { - minWidth: "36px", + minWidth: '36px', }, }, }, MuiCardContent: { styleOverrides: { root: { - ":last-child": { - paddingBottom: "16px", - }, - }, - }, - }, + ':last-child': { + paddingBottom: '16px' + } + } + } + } }, }); diff --git a/web/src/index.js b/web/src/index.js new file mode 100644 index 0000000..659bcb8 --- /dev/null +++ b/web/src/index.js @@ -0,0 +1,6 @@ +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './components/App'; + +const root = createRoot(document.querySelector('#root')); +root.render(); diff --git a/web/src/index.jsx b/web/src/index.jsx deleted file mode 100644 index d60c05a..0000000 --- a/web/src/index.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import * as React from "react"; -import { createRoot } from "react-dom/client"; -import App from "./components/App"; - -const root = createRoot(document.querySelector("#root")); -root.render(); diff --git a/web/vite.config.js b/web/vite.config.js deleted file mode 100644 index ffc80ab..0000000 --- a/web/vite.config.js +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies */ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig(() => ({ - build: { - outDir: "build", - assetsDir: "static/media", - }, - server: { - port: 3000, - }, - plugins: [react()], -}));